Refactor local RSS repository to support other RSS API data sources

This commit is contained in:
Ash 2022-03-18 00:15:53 +08:00
parent a5b81f7f23
commit d105735453
29 changed files with 771 additions and 476 deletions

View File

@ -5,10 +5,7 @@ import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import me.ash.reader.data.repository.AccountRepository
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.OpmlRepository
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.data.repository.*
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource
@ -26,11 +23,14 @@ class App : Application() {
@Inject
lateinit var rssNetworkDataSource: RssNetworkDataSource
@Inject
lateinit var rssHelper: RssHelper
@Inject
lateinit var accountRepository: AccountRepository
@Inject
lateinit var articleRepository: ArticleRepository
lateinit var localRssRepository: LocalRssRepository
@Inject
lateinit var opmlRepository: OpmlRepository
@ -42,10 +42,11 @@ class App : Application() {
super.onCreate()
GlobalScope.launch {
if (accountRepository.isNoAccount()) {
val accountId = accountRepository.addDefaultAccount()
applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, accountId)
val account = accountRepository.addDefaultAccount()
applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, account.id!!)
applicationContext.dataStore.put(DataStoreKeys.CurrentAccountType, account.type)
}
rssRepository.sync(true)
rssRepository.get().doSync(true)
}
}
}

View File

@ -46,4 +46,9 @@ sealed class DataStoreKeys<T> {
override val key: Preferences.Key<Int>
get() = intPreferencesKey("currentAccountId")
}
object CurrentAccountType : DataStoreKeys<Int>() {
override val key: Preferences.Key<Int>
get() = intPreferencesKey("currentAccountType")
}
}

View File

@ -8,7 +8,7 @@ import java.util.*
@Entity(tableName = "account")
data class Account(
@PrimaryKey(autoGenerate = true)
val id: Int? = null,
var id: Int? = null,
@ColumnInfo
var name: String,
@ColumnInfo
@ -18,6 +18,7 @@ data class Account(
) {
object Type {
const val LOCAL = 1
const val FRESH_RSS = 2
const val FEVER = 2
const val GOOGLE_READER = 3
}
}

View File

@ -18,8 +18,8 @@ import java.util.*
)]
)
data class Article(
@PrimaryKey(autoGenerate = true)
val id: Int? = null,
@PrimaryKey
val id: String,
@ColumnInfo
val date: Date,
@ColumnInfo
@ -35,7 +35,7 @@ data class Article(
@ColumnInfo
val link: String,
@ColumnInfo(index = true)
val feedId: Int,
val feedId: String,
@ColumnInfo(index = true)
val accountId: Int,
@ColumnInfo(defaultValue = "true")

View File

@ -167,7 +167,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByGroupIdWhenIsAll(
accountId: Int,
groupId: Int
groupId: String,
): PagingSource<Int, ArticleWithFeed>
@Transaction
@ -188,7 +188,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByGroupIdWhenIsStarred(
accountId: Int,
groupId: Int,
groupId: String,
isStarred: Boolean,
): PagingSource<Int, ArticleWithFeed>
@ -210,7 +210,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByGroupIdWhenIsUnread(
accountId: Int,
groupId: Int,
groupId: String,
isUnread: Boolean,
): PagingSource<Int, ArticleWithFeed>
@ -224,7 +224,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByFeedIdWhenIsAll(
accountId: Int,
feedId: Int
feedId: String
): PagingSource<Int, ArticleWithFeed>
@Transaction
@ -238,7 +238,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByFeedIdWhenIsStarred(
accountId: Int,
feedId: Int,
feedId: String,
isStarred: Boolean,
): PagingSource<Int, ArticleWithFeed>
@ -253,7 +253,7 @@ interface ArticleDao {
)
fun queryArticleWithFeedByFeedIdWhenIsUnread(
accountId: Int,
feedId: Int,
feedId: String,
isUnread: Boolean,
): PagingSource<Int, ArticleWithFeed>
@ -270,7 +270,7 @@ interface ArticleDao {
ORDER BY date DESC LIMIT 1
"""
)
suspend fun queryLatestByFeedId(accountId: Int, feedId: Int): Article?
suspend fun queryLatestByFeedId(accountId: Int, feedId: String): Article?
@Transaction
@Query(

View File

@ -2,6 +2,6 @@ package me.ash.reader.data.article
data class ImportantCount(
val important: Int,
val feedId: Int,
val groupId: Int,
val feedId: String,
val groupId: String,
)

View File

@ -14,8 +14,8 @@ import me.ash.reader.data.group.Group
)],
)
data class Feed(
@PrimaryKey(autoGenerate = true)
val id: Int? = null,
@PrimaryKey
val id: String,
@ColumnInfo
val name: String,
@ColumnInfo
@ -23,7 +23,7 @@ data class Feed(
@ColumnInfo
val url: String,
@ColumnInfo(index = true)
var groupId: Int? = null,
var groupId: String,
@ColumnInfo(index = true)
val accountId: Int,
@ColumnInfo(defaultValue = "false")
@ -33,7 +33,6 @@ data class Feed(
) {
@Ignore
var important: Int? = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@ -57,11 +56,11 @@ data class Feed(
}
override fun hashCode(): Int {
var result = id ?: 0
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + (icon?.contentHashCode() ?: 0)
result = 31 * result + url.hashCode()
result = 31 * result + (groupId ?: 0)
result = 31 * result + groupId.hashCode()
result = 31 * result + accountId
result = 31 * result + isNotification.hashCode()
result = 31 * result + isFullContent.hashCode()

View File

@ -7,8 +7,8 @@ import androidx.room.PrimaryKey
@Entity(tableName = "group")
data class Group(
@PrimaryKey(autoGenerate = true)
val id: Int? = null,
@PrimaryKey
val id: String,
@ColumnInfo
val name: String,
@ColumnInfo(index = true)

View File

@ -4,14 +4,26 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.ash.reader.data.source.FeverApiDataSource
import me.ash.reader.data.source.GoogleReaderApiDataSource
import me.ash.reader.data.source.RssNetworkDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RssNetworkModule {
class RetrofitModule {
@Singleton
@Provides
fun provideRssNetworkDataSource(): RssNetworkDataSource =
RssNetworkDataSource.getInstance()
@Singleton
@Provides
fun provideFeverApiDataSource(): FeverApiDataSource =
FeverApiDataSource.getInstance()
@Singleton
@Provides
fun provideGoogleReaderApiDataSource(): GoogleReaderApiDataSource =
GoogleReaderApiDataSource.getInstance()
}

View File

@ -3,9 +3,11 @@ package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import androidx.paging.PagingSource
import dagger.hilt.android.qualifiers.ApplicationContext
import androidx.work.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.Flow
import me.ash.reader.DataStoreKeys
import me.ash.reader.data.account.AccountDao
import me.ash.reader.data.article.Article
import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.article.ArticleWithFeed
@ -15,17 +17,43 @@ import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.group.Group
import me.ash.reader.data.group.GroupDao
import me.ash.reader.data.group.GroupWithFeed
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.dataStore
import me.ash.reader.get
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ArticleRepository @Inject constructor(
@ApplicationContext
abstract class AbstractRssRepository constructor(
private val context: Context,
private val accountDao: AccountDao,
private val articleDao: ArticleDao,
private val groupDao: GroupDao,
private val feedDao: FeedDao,
private val rssNetworkDataSource: RssNetworkDataSource,
private val workManager: WorkManager,
) {
data class SyncState(
val feedCount: Int = 0,
val syncedCount: Int = 0,
val currentFeedName: String = "",
) {
val isSyncing: Boolean = feedCount != 0 || syncedCount != 0 || currentFeedName != ""
val isNotSyncing: Boolean = !isSyncing
}
abstract suspend fun updateArticleInfo(article: Article)
abstract suspend fun subscribe(feed: Feed, articles: List<Article>)
abstract suspend fun sync(
context: Context,
accountDao: AccountDao,
articleDao: ArticleDao,
feedDao: FeedDao,
rssNetworkDataSource: RssNetworkDataSource
)
fun pullGroups(): Flow<MutableList<Group>> {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
return groupDao.queryAllGroup(accountId)
@ -38,8 +66,8 @@ class ArticleRepository @Inject constructor(
}
fun pullArticles(
groupId: Int? = null,
feedId: Int? = null,
groupId: String? = null,
feedId: String? = null,
isStarred: Boolean = false,
isUnread: Boolean = false,
): PagingSource<Int, ArticleWithFeed> {
@ -91,18 +119,54 @@ class ArticleRepository @Inject constructor(
}
}
suspend fun updateArticleInfo(article: Article) {
articleDao.update(article)
}
suspend fun findArticleById(id: Int): ArticleWithFeed? {
return articleDao.queryById(id)
}
suspend fun subscribe(feed: Feed, articles: List<Article>) {
val feedId = feedDao.insert(feed).toInt()
articleDao.insertList(articles.map {
it.copy(feedId = feedId)
})
fun peekWork(): String {
return workManager.getWorkInfosByTag("sync").get().size.toString()
}
suspend fun doSync(isWork: Boolean? = false) {
if (isWork == true) {
workManager.cancelAllWork()
val syncWorkerRequest: WorkRequest =
PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).addTag("sync").build()
workManager.enqueue(syncWorkerRequest)
} else {
sync(context, accountDao, articleDao, feedDao, rssNetworkDataSource)
}
}
}
@DelicateCoroutinesApi
class SyncWorker(
context: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(context, workerParams) {
@Inject
lateinit var rssRepository: RssRepository
@Inject
lateinit var rssNetworkDataSource: RssNetworkDataSource
override suspend fun doWork(): Result {
Log.i("RLog", "doWork: ")
val db = ReaderDatabase.getInstance(applicationContext)
rssRepository.get().sync(
applicationContext,
db.accountDao(),
db.articleDao(),
db.feedDao(),
rssNetworkDataSource
)
return Result.success()
}
}

View File

@ -24,12 +24,12 @@ class AccountRepository @Inject constructor(
return accountDao.queryAll().isEmpty()
}
suspend fun addDefaultAccount(): Int {
return accountDao.insert(
Account(
name = "Feeds",
type = Account.Type.LOCAL,
)
).toInt()
suspend fun addDefaultAccount(): Account {
return Account(
name = "Reader You",
type = Account.Type.LOCAL,
).apply {
id = accountDao.insert(this).toInt()
}
}
}

View File

@ -0,0 +1,183 @@
package me.ash.reader.data.repository
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.ash.reader.*
import me.ash.reader.data.account.AccountDao
import me.ash.reader.data.article.Article
import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.constant.Symbol
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.group.GroupDao
import me.ash.reader.data.source.RssNetworkDataSource
import java.util.*
import javax.inject.Inject
class LocalRssRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val articleDao: ArticleDao,
private val feedDao: FeedDao,
private val rssHelper: RssHelper,
rssNetworkDataSource: RssNetworkDataSource,
groupDao: GroupDao,
accountDao: AccountDao,
workManager: WorkManager,
) : AbstractRssRepository(
context, accountDao, articleDao, groupDao,
feedDao, rssNetworkDataSource, workManager,
) {
val syncState = MutableStateFlow(SyncState())
private val mutex = Mutex()
override suspend fun updateArticleInfo(article: Article) {
articleDao.update(article)
}
override suspend fun subscribe(feed: Feed, articles: List<Article>) {
feedDao.insert(feed)
articleDao.insertList(articles.map {
it.copy(feedId = feed.id)
})
}
override suspend fun sync(
context: Context,
accountDao: AccountDao,
articleDao: ArticleDao,
feedDao: FeedDao,
rssNetworkDataSource: RssNetworkDataSource
) {
mutex.withLock {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)
?: return
val feeds = feedDao.queryAll(accountId)
val feedNotificationMap = mutableMapOf<String, Boolean>()
feeds.forEach { feed ->
feedNotificationMap[feed.id] = feed.isNotification
}
val preTime = System.currentTimeMillis()
val chunked = feeds.chunked(6)
chunked.forEachIndexed { index, item ->
item.forEach {
Log.i("RlOG", "chunked $index: ${it.name}")
}
}
val flows = mutableListOf<Flow<List<Article>>>()
repeat(chunked.size) {
flows.add(flow {
val articles = mutableListOf<Article>()
chunked[it].forEach { feed ->
val latest = articleDao.queryLatestByFeedId(accountId, feed.id)
articles.addAll(
rssHelper.queryRssXml(
rssNetworkDataSource,
accountId,
feed,
latest?.title,
).also {
if (feed.icon == null && it.isNotEmpty()) {
rssHelper.queryRssIcon(feedDao, feed, it.first().link)
}
}
)
syncState.update {
it.copy(
feedCount = feeds.size,
syncedCount = syncState.value.syncedCount + 1,
currentFeedName = feed.name
)
}
}
emit(articles)
})
}
combine(
flows
) {
val notificationManager: NotificationManager =
getSystemService(
context,
NotificationManager::class.java
) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE,
"文章更新",
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
it.forEach { articleList ->
val ids = articleDao.insertList(articleList)
articleList.forEachIndexed { index, article ->
Log.i("RlOG", "combine ${article.feedId}: ${article.title}")
if (feedNotificationMap[article.feedId] == true) {
val builder = NotificationCompat.Builder(
context,
Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE
).setSmallIcon(R.drawable.ic_launcher_foreground)
.setGroup(Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE)
.setContentTitle(article.title)
.setContentText(article.shortDescription)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(
PendingIntent.getActivity(
context,
ids[index].toInt(),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(
Symbol.EXTRA_ARTICLE_ID,
ids[index].toInt()
)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
notificationManager.notify(
ids[index].toInt(),
builder.build().apply {
flags = Notification.FLAG_AUTO_CANCEL
}
)
}
}
}
}.buffer().onCompletion {
val afterTime = System.currentTimeMillis()
Log.i("RlOG", "onCompletion: ${afterTime - preTime}")
accountDao.queryById(accountId)?.let { account ->
accountDao.update(
account.apply {
updateAt = Date()
}
)
}
syncState.update {
it.copy(
feedCount = 0,
syncedCount = 0,
currentFeedName = ""
)
}
}.collect()
}
}
}

View File

@ -16,8 +16,8 @@ class OpmlRepository @Inject constructor(
try {
val groupWithFeedList = opmlLocalDataSource.parseFileInputStream(inputStream)
groupWithFeedList.forEach { groupWithFeed ->
val id = groupDao.insert(groupWithFeed.group).toInt()
groupWithFeed.feeds.forEach { it.groupId = id }
groupDao.insert(groupWithFeed.group)
groupWithFeed.feeds.forEach { it.groupId = groupWithFeed.group.id }
feedDao.insertList(groupWithFeed.feeds)
}
} catch (e: Exception) {

View File

@ -0,0 +1,177 @@
package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import me.ash.reader.DataStoreKeys
import me.ash.reader.data.article.Article
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.feed.FeedWithArticle
import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.dataStore
import me.ash.reader.get
import net.dankito.readability4j.Readability4J
import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.*
import java.io.IOException
import java.util.*
import javax.inject.Inject
class RssHelper @Inject constructor(
@ApplicationContext
private val context: Context,
private val rssNetworkDataSource: RssNetworkDataSource,
) {
@Throws(Exception::class)
suspend fun searchFeed(feedLink: String): FeedWithArticle {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
val parseRss = rssNetworkDataSource.parseRss(feedLink)
val feed = Feed(
id = UUID.randomUUID().toString(),
name = parseRss.title!!,
url = feedLink,
groupId = UUID.randomUUID().toString(),
accountId = accountId,
)
val articles = mutableListOf<Article>()
parseRss.items.forEach {
articles.add(
Article(
id = UUID.randomUUID().toString(),
accountId = accountId,
feedId = feed.id,
date = Date(it.publishDate.toString()),
title = it.title.toString(),
author = it.author,
rawDescription = it.description.toString(),
shortDescription = (Readability4JExtended("", it.description.toString())
.parse().textContent ?: "").trim().run {
if (this.length > 100) this.substring(0, 100)
else this
},
link = it.link ?: "",
)
)
}
return FeedWithArticle(feed, articles)
}
fun parseDescriptionContent(link: String, content: String): String {
val readability4J: Readability4J = Readability4JExtended(link, content)
val article = readability4J.parse()
val element = article.articleContent
return element.toString()
}
fun parseFullContent(link: String, title: String, callback: (String) -> Unit) {
OkHttpClient()
.newCall(Request.Builder().url(link).build())
.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
callback(e.message.toString())
}
override fun onResponse(call: Call, response: Response) {
val content = response.body?.string()
val readability4J: Readability4J =
Readability4JExtended(link, content ?: "")
val articleContent = readability4J.parse().articleContent
if (articleContent == null) {
callback("")
} else {
val h1Element = articleContent.selectFirst("h1")
if (h1Element != null && h1Element.hasText() && h1Element.text() == title) {
h1Element.remove()
}
callback(articleContent.toString())
}
}
})
}
suspend fun queryRssXml(
rssNetworkDataSource: RssNetworkDataSource,
accountId: Int,
feed: Feed,
latestTitle: String? = null,
): List<Article> {
val a = mutableListOf<Article>()
try {
val parseRss = rssNetworkDataSource.parseRss(feed.url)
parseRss.items.forEach {
if (latestTitle != null && latestTitle == it.title) return a
Log.i("RLog", "request rss ${feed.name}: ${it.title}")
a.add(
Article(
id = UUID.randomUUID().toString(),
accountId = accountId,
feedId = feed.id,
date = Date(it.publishDate.toString()),
title = it.title.toString(),
author = it.author,
rawDescription = it.description.toString(),
shortDescription = (Readability4JExtended("", it.description.toString())
.parse().textContent ?: "").trim().run {
if (this.length > 100) this.substring(0, 100)
else this
},
link = it.link ?: "",
)
)
}
return a
} catch (e: Exception) {
Log.e("RLog", "error ${feed.name}: ${e.message}")
return a
}
}
suspend fun queryRssIcon(
feedDao: FeedDao,
feed: Feed,
articleLink: String?,
) {
if (articleLink == null) return
val execute = OkHttpClient()
.newCall(Request.Builder().url(articleLink).build())
.execute()
val content = execute.body?.string()
val regex =
Regex("""<link(.+?)rel="shortcut icon"(.+?)href="(.+?)"""")
if (content != null) {
var iconLink = regex
.find(content)
?.groups?.get(3)
?.value
Log.i("rlog", "queryRssIcon: $iconLink")
if (iconLink != null) {
if (iconLink.startsWith("//")) {
iconLink = "http:$iconLink"
}
if (iconLink.startsWith("/")) {
val domainRegex =
Regex("""http(s)?://(([\w-]+\.)+\w+(:\d{1,5})?)""")
iconLink =
"http://${domainRegex.find(articleLink)?.groups?.get(2)?.value}$iconLink"
}
saveRssIcon(feedDao, feed, iconLink)
} else {
// saveRssIcon(feedDao, feed, "")
}
} else {
// saveRssIcon(feedDao, feed, "")
}
}
private suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
val execute = OkHttpClient()
.newCall(Request.Builder().url(iconLink).build())
.execute()
feedDao.update(
feed.apply {
icon = execute.body?.bytes()
}
)
}
}

View File

@ -1,391 +1,27 @@
package me.ash.reader.data.repository
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.ash.reader.*
import me.ash.reader.R
import me.ash.reader.data.account.AccountDao
import me.ash.reader.data.article.Article
import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.constant.Symbol
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.feed.FeedWithArticle
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource
import net.dankito.readability4j.Readability4J
import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.*
import java.io.IOException
import java.util.*
import java.util.concurrent.TimeUnit
import me.ash.reader.DataStoreKeys
import me.ash.reader.data.account.Account
import me.ash.reader.dataStore
import me.ash.reader.get
import javax.inject.Inject
@DelicateCoroutinesApi
class RssRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val accountDao: AccountDao,
private val articleDao: ArticleDao,
private val feedDao: FeedDao,
private val rssNetworkDataSource: RssNetworkDataSource,
private val workManager: WorkManager,
private val localRssRepository: LocalRssRepository,
// private val feverRssRepository: FeverRssRepository,
// private val googleReaderRssRepository: GoogleReaderRssRepository,
) {
@Throws(Exception::class)
suspend fun searchFeed(feedLink: String): FeedWithArticle {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
val parseRss = rssNetworkDataSource.parseRss(feedLink)
val feed = Feed(
name = parseRss.title!!,
url = feedLink,
groupId = 0,
accountId = accountId,
)
val articles = mutableListOf<Article>()
parseRss.items.forEach {
articles.add(
Article(
accountId = accountId,
feedId = feed.id ?: 0,
date = Date(it.publishDate.toString()),
title = it.title.toString(),
author = it.author,
rawDescription = it.description.toString(),
shortDescription = (Readability4JExtended("", it.description.toString())
.parse().textContent ?: "").trim().run {
if (this.length > 100) this.substring(0, 100)
else this
},
link = it.link ?: "",
)
)
}
return FeedWithArticle(feed, articles)
fun get() = when (getAccountType()) {
// Account.Type.LOCAL -> localRssRepository
Account.Type.LOCAL -> localRssRepository
// Account.Type.FEVER -> feverRssRepository
// Account.Type.GOOGLE_READER -> googleReaderRssRepository
else -> throw IllegalStateException("Unknown account type: ${getAccountType()}")
}
fun parseDescriptionContent(link: String, content: String): String {
val readability4J: Readability4J = Readability4JExtended(link, content)
val article = readability4J.parse()
val element = article.articleContent
return element.toString()
}
fun parseFullContent(link: String, title: String, callback: (String) -> Unit) {
OkHttpClient()
.newCall(Request.Builder().url(link).build())
.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
callback(e.message.toString())
}
override fun onResponse(call: Call, response: Response) {
val content = response.body?.string()
val readability4J: Readability4J =
Readability4JExtended(link, content ?: "")
val articleContent = readability4J.parse().articleContent
if (articleContent == null) {
callback("")
} else {
val h1Element = articleContent.selectFirst("h1")
if (h1Element != null && h1Element.hasText() && h1Element.text() == title) {
h1Element.remove()
}
callback(articleContent.toString())
}
}
})
}
fun peekWork(): String {
return workManager.getWorkInfosByTag("sync").get().size.toString()
}
suspend fun sync(isWork: Boolean? = false) {
if (isWork == true) {
workManager.cancelAllWork()
val syncWorkerRequest: WorkRequest =
PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).addTag("sync").build()
workManager.enqueue(syncWorkerRequest)
} else {
normalSync(context, accountDao, articleDao, feedDao, rssNetworkDataSource)
}
}
@DelicateCoroutinesApi
companion object {
data class SyncState(
val feedCount: Int = 0,
val syncedCount: Int = 0,
val currentFeedName: String = "",
) {
val isSyncing: Boolean = feedCount != 0 || syncedCount != 0 || currentFeedName != ""
val isNotSyncing: Boolean = !isSyncing
}
val syncState = MutableStateFlow(SyncState())
private val mutex = Mutex()
suspend fun normalSync(
context: Context,
accountDao: AccountDao,
articleDao: ArticleDao,
feedDao: FeedDao,
rssNetworkDataSource: RssNetworkDataSource
) {
doSync(context, accountDao, articleDao, feedDao, rssNetworkDataSource)
}
suspend fun workerSync(context: Context) {
val db = ReaderDatabase.getInstance(context)
doSync(
context,
db.accountDao(),
db.articleDao(),
db.feedDao(),
RssNetworkDataSource.getInstance()
)
}
private suspend fun doSync(
context: Context,
accountDao: AccountDao,
articleDao: ArticleDao,
feedDao: FeedDao,
rssNetworkDataSource: RssNetworkDataSource
) {
mutex.withLock {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)
?: return
val feeds = feedDao.queryAll(accountId)
val feedNotificationMap = mutableMapOf<Int, Boolean>()
feeds.forEach { feed ->
feedNotificationMap[feed.id ?: 0] = feed.isNotification
}
val preTime = System.currentTimeMillis()
val chunked = feeds.chunked(6)
chunked.forEachIndexed { index, item ->
item.forEach {
Log.i("RlOG", "chunked $index: ${it.name}")
}
}
val flows = mutableListOf<Flow<List<Article>>>()
repeat(chunked.size) {
flows.add(flow {
val articles = mutableListOf<Article>()
chunked[it].forEach { feed ->
val latest = articleDao.queryLatestByFeedId(accountId, feed.id ?: 0)
articles.addAll(
queryRssXml(
rssNetworkDataSource,
accountId,
feed,
latest?.title,
).also {
if (feed.icon == null && it.isNotEmpty()) {
queryRssIcon(feedDao, feed, it.first().link)
}
}
)
syncState.update {
it.copy(
feedCount = feeds.size,
syncedCount = syncState.value.syncedCount + 1,
currentFeedName = feed.name
)
}
}
emit(articles)
})
}
combine(
flows
) {
val notificationManager: NotificationManager =
getSystemService(
context,
NotificationManager::class.java
) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE,
"文章更新",
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
it.forEach { articleList ->
val ids = articleDao.insertList(articleList)
articleList.forEachIndexed { index, article ->
Log.i("RlOG", "combine ${article.feedId}: ${article.title}")
if (feedNotificationMap[article.feedId] == true) {
val builder = NotificationCompat.Builder(
context,
Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE
).setSmallIcon(R.drawable.ic_launcher_foreground)
.setGroup(Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE)
.setContentTitle(article.title)
.setContentText(article.shortDescription)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(
PendingIntent.getActivity(
context,
ids[index].toInt(),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(
Symbol.EXTRA_ARTICLE_ID,
ids[index].toInt()
)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
notificationManager.notify(
ids[index].toInt(),
builder.build().apply {
flags = Notification.FLAG_AUTO_CANCEL
}
)
}
}
}
}.buffer().onCompletion {
val afterTime = System.currentTimeMillis()
Log.i("RlOG", "onCompletion: ${afterTime - preTime}")
accountDao.queryById(accountId)?.let { account ->
accountDao.update(
account.apply {
updateAt = Date()
}
)
}
syncState.update {
it.copy(
feedCount = 0,
syncedCount = 0,
currentFeedName = ""
)
}
}.collect()
}
}
private suspend fun queryRssXml(
rssNetworkDataSource: RssNetworkDataSource,
accountId: Int,
feed: Feed,
latestTitle: String? = null,
): List<Article> {
val a = mutableListOf<Article>()
try {
val parseRss = rssNetworkDataSource.parseRss(feed.url)
parseRss.items.forEach {
if (latestTitle != null && latestTitle == it.title) return a
Log.i("RLog", "request rss ${feed.name}: ${it.title}")
a.add(
Article(
accountId = accountId,
feedId = feed.id ?: 0,
date = Date(it.publishDate.toString()),
title = it.title.toString(),
author = it.author,
rawDescription = it.description.toString(),
shortDescription = (Readability4JExtended("", it.description.toString())
.parse().textContent ?: "").trim().run {
if (this.length > 100) this.substring(0, 100)
else this
},
link = it.link ?: "",
)
)
}
return a
} catch (e: Exception) {
Log.e("RLog", "error ${feed.name}: ${e.message}")
return a
}
}
private suspend fun queryRssIcon(
feedDao: FeedDao,
feed: Feed,
articleLink: String?,
) {
if (articleLink == null) return
val execute = OkHttpClient()
.newCall(Request.Builder().url(articleLink).build())
.execute()
val content = execute.body?.string()
val regex =
Regex("""<link(.+?)rel="shortcut icon"(.+?)href="(.+?)"""")
if (content != null) {
var iconLink = regex
.find(content)
?.groups?.get(3)
?.value
Log.i("rlog", "queryRssIcon: $iconLink")
if (iconLink != null) {
if (iconLink.startsWith("//")) {
iconLink = "http:$iconLink"
}
if (iconLink.startsWith("/")) {
val domainRegex =
Regex("""http(s)?://(([\w-]+\.)+\w+(:\d{1,5})?)""")
iconLink =
"http://${domainRegex.find(articleLink)?.groups?.get(2)?.value}$iconLink"
}
saveRssIcon(feedDao, feed, iconLink)
} else {
// saveRssIcon(feedDao, feed, "")
}
} else {
// saveRssIcon(feedDao, feed, "")
}
}
private suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
val execute = OkHttpClient()
.newCall(Request.Builder().url(iconLink).build())
.execute()
feedDao.update(
feed.apply {
icon = execute.body?.bytes()
}
)
}
}
private fun getAccountType(): Int = context.dataStore.get(DataStoreKeys.CurrentAccountType)!!
}
@DelicateCoroutinesApi
class SyncWorker(
context: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Log.i("RLog", "doWork: ")
RssRepository.workerSync(applicationContext)
return Result.success()
}
}

View File

@ -0,0 +1,38 @@
package me.ash.reader.data.source
import com.github.muhrifqii.parserss.ParseRSS
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParserFactory
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
interface FeverApiDataSource {
@Multipart
@POST("fever.php?api&groups")
fun groups(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.Groups>
@Multipart
@POST("fever.php?api&feeds")
fun feeds(@Part("api_key") apiKey: RequestBody?="1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.Feed>
companion object {
private var instance: FeverApiDataSource? = null
fun getInstance(): FeverApiDataSource {
return instance ?: synchronized(this) {
ParseRSS.init(XmlPullParserFactory.newInstance())
instance ?: Retrofit.Builder()
.baseUrl("http://10.0.2.2/api/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(FeverApiDataSource::class.java).also {
instance = it
}
}
}
}
}

View File

@ -0,0 +1,54 @@
package me.ash.reader.data.source
object FeverApiDto {
// &groups
data class Groups(
val apiVersion: Int,
val auth: Int,
val lastRefreshedOnTime: Long,
val groups: List<GroupItem>,
val feedsGroups: List<FeedsGroupsItem>,
)
data class GroupItem(
val id: Int,
val title: String,
)
data class FeedsGroupsItem(
val groupId: Int,
val feedsIds: String,
)
// &feeds
data class Feed(
val apiVersion: Int,
val auth: Int,
val lastRefreshedOnTime: Long,
val feeds: List<FeedItem>,
val feedsGroups: List<FeedsGroupsItem>,
)
data class FeedItem(
val id: Int,
val favicon_id: Int,
val title: String,
val url: String,
val siteUrl: String,
val isSpark: Int,
val lastRefreshedOnTime: Long,
)
// &favicons
data class Favicons(
val apiVersion: Int,
val auth: Int,
val lastRefreshedOnTime: Long,
val favicons: List<FaviconItem>,
)
data class FaviconItem(
val id: Int,
val data: String,
)
}

View File

@ -0,0 +1,46 @@
package me.ash.reader.data.source
import com.github.muhrifqii.parserss.ParseRSS
import org.xmlpull.v1.XmlPullParserFactory
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Headers
import retrofit2.http.POST
interface GoogleReaderApiDataSource {
@POST("accounts/ClientLogin")
fun login(Email: String, Passwd: String): Call<String>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/subscription/list?output=json")
fun subscriptionList(): Call<GoogleReaderApiDto.SubscriptionList>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/unread-count?output=json")
fun unreadCount(): Call<GoogleReaderApiDto.UnreadCount>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/tag/list?output=json")
fun tagList(): Call<GoogleReaderApiDto.TagList>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/stream/contents/reading-list")
fun readingList(): Call<GoogleReaderApiDto.ReadingList>
companion object {
private var instance: GoogleReaderApiDataSource? = null
fun getInstance(): GoogleReaderApiDataSource {
return instance ?: synchronized(this) {
ParseRSS.init(XmlPullParserFactory.newInstance())
instance ?: Retrofit.Builder()
.baseUrl("http://10.0.2.2/api/greader.php/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(GoogleReaderApiDataSource::class.java).also {
instance = it
}
}
}
}
}

View File

@ -0,0 +1,78 @@
package me.ash.reader.data.source
object GoogleReaderApiDto {
// subscription/list?output=json
data class SubscriptionList(
val subscriptions: List<SubscriptionItem>? = null,
)
data class SubscriptionItem(
val id: String? = null,
val title: String? = null,
val categories: List<CategoryItem>? = null,
val url: String? = null,
val htmlUrl: String? = null,
val iconUrl: String? = null,
)
data class CategoryItem(
val id: String? = null,
val label: String? = null,
)
// unread-count?output=json
data class UnreadCount(
val max: Int? = null,
val unreadcounts: List<UnreadCountItem>? = null,
)
data class UnreadCountItem(
val id: String? = null,
val count: Int? = null,
val newestItemTimestampUsec: String? = null,
)
// tag/list?output=json
data class TagList(
val tags: List<TagItem>? = null,
)
data class TagItem(
val id: String? = null,
val type: String? = null,
)
// stream/contents/reading-list?output=json
data class ReadingList(
val id: String? = null,
val updated: Long? = null,
val items: List<Item>? = null,
)
data class Item(
val id: String? = null,
val crawlTimeMsec: String? = null,
val timestampUsec: String? = null,
val published: Long? = null,
val title: String? = null,
val summary: Summary? = null,
val categories: List<String>? = null,
val origin: List<OriginItem>? = null,
val author: String? = null,
)
data class Summary(
val content: String? = null,
val canonical: List<CanonicalItem>? = null,
val alternate: List<CanonicalItem>? = null,
)
data class CanonicalItem(
val href: String? = null,
)
data class OriginItem(
val streamId: String? = null,
val title: String? = null,
)
}

View File

@ -14,6 +14,7 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
import java.io.InputStream
import java.util.*
import javax.inject.Inject
class OpmlLocalDataSource @Inject constructor(
@ -42,9 +43,10 @@ class OpmlLocalDataSource @Inject constructor(
Log.i("RLog", "rss: ${title} , ${xmlUrl}")
groupWithFeedList.last().feeds.add(
Feed(
id = UUID.randomUUID().toString(),
name = title,
url = xmlUrl,
groupId = 0,
groupId = UUID.randomUUID().toString(),
accountId = accountId,
)
)
@ -54,6 +56,7 @@ class OpmlLocalDataSource @Inject constructor(
groupWithFeedList.add(
GroupWithFeed(
group = Group(
id = UUID.randomUUID().toString(),
name = title,
accountId = accountId,
),

View File

@ -46,7 +46,7 @@ fun HomePage(
readViewModel.dispatch(ReadViewAction.ScrollToItem(2))
scope.launch {
val article =
readViewModel.articleRepository.findArticleById(it.toString().toInt())
readViewModel.rssRepository.get().findArticleById(it.toString().toInt())
?: return@launch
readViewModel.dispatch(ReadViewAction.InitData(article))
if (article.feed.isFullContent) readViewModel.dispatch(ReadViewAction.RenderFullContent)

View File

@ -31,7 +31,7 @@ class HomeViewModel @Inject constructor(
private val _filterState = MutableStateFlow(FilterState())
val filterState = _filterState.asStateFlow()
val syncState = RssRepository.syncState
val syncState = rssRepository.get().syncState
fun dispatch(action: HomeViewAction) {
when (action) {
@ -47,7 +47,7 @@ class HomeViewModel @Inject constructor(
private fun sync(callback: () -> Unit = {}) {
viewModelScope.launch(Dispatchers.IO) {
rssRepository.sync()
rssRepository.get().doSync()
callback()
}
}

View File

@ -12,13 +12,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import me.ash.reader.data.article.ArticleWithFeed
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.RssRepository
import javax.inject.Inject
@HiltViewModel
class ArticleViewModel @Inject constructor(
private val articleRepository: ArticleRepository,
private val rssRepository: RssRepository,
) : ViewModel() {
private val _viewState = MutableStateFlow(ArticleViewState())
@ -41,19 +39,19 @@ class ArticleViewModel @Inject constructor(
private fun peekSyncWork() {
_viewState.update {
it.copy(
syncWorkInfo = rssRepository.peekWork()
syncWorkInfo = rssRepository.get().peekWork()
)
}
}
private fun fetchData(
groupId: Int? = null,
feedId: Int? = null,
groupId: String? = null,
feedId: String? = null,
isStarred: Boolean,
isUnread: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) {
articleRepository.pullImportant(isStarred, true)
rssRepository.get().pullImportant(isStarred, true)
.collect { importantList ->
_viewState.update {
it.copy(
@ -65,7 +63,7 @@ class ArticleViewModel @Inject constructor(
_viewState.update {
it.copy(
pagingData = Pager(PagingConfig(pageSize = 10)) {
articleRepository.pullArticles(
rssRepository.get().pullArticles(
groupId = groupId,
feedId = feedId,
isStarred = isStarred,
@ -99,8 +97,8 @@ data class ArticleViewState(
sealed class ArticleViewAction {
data class FetchData(
val groupId: Int? = null,
val feedId: Int? = null,
val groupId: String? = null,
val feedId: String? = null,
val isStarred: Boolean,
val isUnread: Boolean,
) : ArticleViewAction()

View File

@ -11,15 +11,15 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.account.Account
import me.ash.reader.data.group.GroupWithFeed
import me.ash.reader.data.repository.AccountRepository
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.OpmlRepository
import me.ash.reader.data.repository.RssRepository
import java.io.InputStream
import javax.inject.Inject
@HiltViewModel
class FeedViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val articleRepository: ArticleRepository,
private val rssRepository: RssRepository,
private val opmlRepository: OpmlRepository,
) : ViewModel() {
private val _viewState = MutableStateFlow(FeedViewState())
@ -62,11 +62,11 @@ class FeedViewModel @Inject constructor(
private suspend fun pullFeeds(isStarred: Boolean, isUnread: Boolean) {
combine(
articleRepository.pullFeeds(),
articleRepository.pullImportant(isStarred, isUnread),
rssRepository.get().pullFeeds(),
rssRepository.get().pullImportant(isStarred, isUnread),
) { groupWithFeedList, importantList ->
val groupImportantMap = mutableMapOf<Int, Int>()
val feedImportantMap = mutableMapOf<Int, Int>()
val groupImportantMap = mutableMapOf<String, Int>()
val feedImportantMap = mutableMapOf<String, Int>()
importantList.groupBy { it.groupId }.forEach { (i, list) ->
var groupImportantSum = 0
list.forEach {

View File

@ -28,10 +28,10 @@ fun ResultViewPage(
groups: List<Group> = emptyList(),
selectedNotificationPreset: Boolean = false,
selectedFullContentParsePreset: Boolean = false,
selectedGroupId: Int = 0,
selectedGroupId: String = "",
notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {},
groupOnClick: (groupId: Int) -> Unit = {},
groupOnClick: (groupId: String) -> Unit = {},
onKeyboardAction: () -> Unit = {},
) {
Column {
@ -133,8 +133,8 @@ private fun Preset(
@Composable
private fun AddToGroup(
groups: List<Group>,
selectedGroupId: Int,
groupOnClick: (groupId: Int) -> Unit = {},
selectedGroupId: String,
groupOnClick: (groupId: String) -> Unit = {},
onKeyboardAction: () -> Unit = {},
) {
Text(
@ -152,7 +152,7 @@ private fun AddToGroup(
SelectionChip(
modifier = Modifier.animateContentSize(),
selected = it.id == selectedGroupId,
onClick = { groupOnClick(it.id ?: 0) },
onClick = { groupOnClick(it.id) },
) {
Text(
text = it.name,

View File

@ -82,7 +82,7 @@ fun SubscribeDialog(
groups = groupsState.value,
selectedNotificationPreset = viewState.notificationPreset,
selectedFullContentParsePreset = viewState.fullContentParsePreset,
selectedGroupId = viewState.selectedGroupId ?: 0,
selectedGroupId = viewState.selectedGroupId,
pagerState = viewState.pagerState,
notificationPresetOnClick = {
viewModel.dispatch(SubscribeViewAction.ChangeNotificationPreset)

View File

@ -13,7 +13,7 @@ import me.ash.reader.data.article.Article
import me.ash.reader.data.constant.Symbol
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.group.Group
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.RssHelper
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.formatUrl
import me.ash.reader.ui.extension.animateScrollToPage
@ -22,8 +22,8 @@ import javax.inject.Inject
@OptIn(ExperimentalPagerApi::class)
@HiltViewModel
class SubscribeViewModel @Inject constructor(
private val articleRepository: ArticleRepository,
private val rssRepository: RssRepository,
private val rssHelper: RssHelper,
) : ViewModel() {
private val _viewState = MutableStateFlow(SubScribeViewState())
val viewState: StateFlow<SubScribeViewState> = _viewState.asStateFlow()
@ -48,7 +48,7 @@ class SubscribeViewModel @Inject constructor(
private fun init() {
_viewState.update {
it.copy(
groups = articleRepository.pullGroups()
groups = rssRepository.get().pullGroups()
)
}
}
@ -64,7 +64,7 @@ class SubscribeViewModel @Inject constructor(
articles = emptyList(),
notificationPreset = false,
fullContentParsePreset = false,
selectedGroupId = null,
selectedGroupId = "",
groups = emptyFlow(),
)
}
@ -73,9 +73,9 @@ class SubscribeViewModel @Inject constructor(
private fun subscribe() {
val feed = _viewState.value.feed ?: return
val articles = _viewState.value.articles
val groupId = _viewState.value.selectedGroupId ?: 0
val groupId = _viewState.value.selectedGroupId
viewModelScope.launch(Dispatchers.IO) {
articleRepository.subscribe(
rssRepository.get().subscribe(
feed.copy(
groupId = groupId,
isNotification = _viewState.value.notificationPreset,
@ -86,7 +86,7 @@ class SubscribeViewModel @Inject constructor(
}
}
private fun selectedGroup(groupId: Int? = null) {
private fun selectedGroup(groupId: String) {
_viewState.update {
it.copy(
selectedGroupId = groupId,
@ -127,7 +127,7 @@ class SubscribeViewModel @Inject constructor(
}
viewModelScope.launch(Dispatchers.IO) {
try {
val feedWithArticle = rssRepository.searchFeed(_viewState.value.inputContent)
val feedWithArticle = rssHelper.searchFeed(_viewState.value.inputContent)
_viewState.update {
it.copy(
feed = feedWithArticle.feed,
@ -174,7 +174,7 @@ data class SubScribeViewState(
val articles: List<Article> = emptyList(),
val notificationPreset: Boolean = false,
val fullContentParsePreset: Boolean = false,
val selectedGroupId: Int? = null,
val selectedGroupId: String = "",
val groups: Flow<List<Group>> = emptyFlow(),
val pagerState: PagerState = PagerState(),
)
@ -198,7 +198,7 @@ sealed class SubscribeViewAction {
object ChangeFullContentParsePreset : SubscribeViewAction()
data class SelectedGroup(
val groupId: Int? = null
val groupId: String
) : SubscribeViewAction()
object Subscribe : SubscribeViewAction()

View File

@ -21,11 +21,11 @@ fun SubscribeViewPager(
groups: List<Group> = emptyList(),
selectedNotificationPreset: Boolean = false,
selectedFullContentParsePreset: Boolean = false,
selectedGroupId: Int = 0,
selectedGroupId: String = "",
pagerState: PagerState = com.google.accompanist.pager.rememberPagerState(),
notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {},
groupOnClick: (groupId: Int) -> Unit = {},
groupOnClick: (groupId: String) -> Unit = {},
onResultKeyboardAction: () -> Unit = {},
) {
ViewPager(

View File

@ -10,14 +10,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.ash.reader.data.article.ArticleWithFeed
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.RssHelper
import me.ash.reader.data.repository.RssRepository
import javax.inject.Inject
@HiltViewModel
class ReadViewModel @Inject constructor(
val articleRepository: ArticleRepository,
private val rssRepository: RssRepository,
val rssRepository: RssRepository,
private val rssHelper: RssHelper,
) : ViewModel() {
private val _viewState = MutableStateFlow(ReadViewState())
val viewState: StateFlow<ReadViewState> = _viewState.asStateFlow()
@ -44,7 +44,7 @@ class ReadViewModel @Inject constructor(
private fun renderDescriptionContent() {
_viewState.update {
it.copy(
content = rssRepository.parseDescriptionContent(
content = rssHelper.parseDescriptionContent(
link = it.articleWithFeed?.article?.link ?: "",
content = it.articleWithFeed?.article?.rawDescription ?: "",
)
@ -54,7 +54,7 @@ class ReadViewModel @Inject constructor(
private fun renderFullContent() {
changeLoading(true)
rssRepository.parseFullContent(
rssHelper.parseFullContent(
_viewState.value.articleWithFeed?.article?.link ?: "",
_viewState.value.articleWithFeed?.article?.title ?: ""
) { content ->
@ -76,7 +76,7 @@ class ReadViewModel @Inject constructor(
)
}
viewModelScope.launch {
articleRepository.updateArticleInfo(
rssRepository.get().updateArticleInfo(
it.article.copy(
isUnread = isUnread
)
@ -97,7 +97,7 @@ class ReadViewModel @Inject constructor(
)
}
viewModelScope.launch {
articleRepository.updateArticleInfo(
rssRepository.get().updateArticleInfo(
it.article.copy(
isStarred = isStarred
)