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.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.data.repository.AccountRepository import me.ash.reader.data.repository.*
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.source.OpmlLocalDataSource import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.ReaderDatabase import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource import me.ash.reader.data.source.RssNetworkDataSource
@ -26,11 +23,14 @@ class App : Application() {
@Inject @Inject
lateinit var rssNetworkDataSource: RssNetworkDataSource lateinit var rssNetworkDataSource: RssNetworkDataSource
@Inject
lateinit var rssHelper: RssHelper
@Inject @Inject
lateinit var accountRepository: AccountRepository lateinit var accountRepository: AccountRepository
@Inject @Inject
lateinit var articleRepository: ArticleRepository lateinit var localRssRepository: LocalRssRepository
@Inject @Inject
lateinit var opmlRepository: OpmlRepository lateinit var opmlRepository: OpmlRepository
@ -42,10 +42,11 @@ class App : Application() {
super.onCreate() super.onCreate()
GlobalScope.launch { GlobalScope.launch {
if (accountRepository.isNoAccount()) { if (accountRepository.isNoAccount()) {
val accountId = accountRepository.addDefaultAccount() val account = accountRepository.addDefaultAccount()
applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, accountId) 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> override val key: Preferences.Key<Int>
get() = intPreferencesKey("currentAccountId") 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") @Entity(tableName = "account")
data class Account( data class Account(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
val id: Int? = null, var id: Int? = null,
@ColumnInfo @ColumnInfo
var name: String, var name: String,
@ColumnInfo @ColumnInfo
@ -18,6 +18,7 @@ data class Account(
) { ) {
object Type { object Type {
const val LOCAL = 1 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( data class Article(
@PrimaryKey(autoGenerate = true) @PrimaryKey
val id: Int? = null, val id: String,
@ColumnInfo @ColumnInfo
val date: Date, val date: Date,
@ColumnInfo @ColumnInfo
@ -35,7 +35,7 @@ data class Article(
@ColumnInfo @ColumnInfo
val link: String, val link: String,
@ColumnInfo(index = true) @ColumnInfo(index = true)
val feedId: Int, val feedId: String,
@ColumnInfo(index = true) @ColumnInfo(index = true)
val accountId: Int, val accountId: Int,
@ColumnInfo(defaultValue = "true") @ColumnInfo(defaultValue = "true")

View File

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

View File

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

View File

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

View File

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

View File

@ -4,14 +4,26 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent 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 me.ash.reader.data.source.RssNetworkDataSource
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RssNetworkModule { class RetrofitModule {
@Singleton @Singleton
@Provides @Provides
fun provideRssNetworkDataSource(): RssNetworkDataSource = fun provideRssNetworkDataSource(): RssNetworkDataSource =
RssNetworkDataSource.getInstance() 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.content.Context
import android.util.Log import android.util.Log
import androidx.paging.PagingSource import androidx.paging.PagingSource
import dagger.hilt.android.qualifiers.ApplicationContext import androidx.work.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import me.ash.reader.DataStoreKeys 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.Article
import me.ash.reader.data.article.ArticleDao import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.article.ArticleWithFeed 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.Group
import me.ash.reader.data.group.GroupDao import me.ash.reader.data.group.GroupDao
import me.ash.reader.data.group.GroupWithFeed 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.dataStore
import me.ash.reader.get import me.ash.reader.get
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
class ArticleRepository @Inject constructor( abstract class AbstractRssRepository constructor(
@ApplicationContext
private val context: Context, private val context: Context,
private val accountDao: AccountDao,
private val articleDao: ArticleDao, private val articleDao: ArticleDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val feedDao: FeedDao, 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>> { fun pullGroups(): Flow<MutableList<Group>> {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0 val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
return groupDao.queryAllGroup(accountId) return groupDao.queryAllGroup(accountId)
@ -38,8 +66,8 @@ class ArticleRepository @Inject constructor(
} }
fun pullArticles( fun pullArticles(
groupId: Int? = null, groupId: String? = null,
feedId: Int? = null, feedId: String? = null,
isStarred: Boolean = false, isStarred: Boolean = false,
isUnread: Boolean = false, isUnread: Boolean = false,
): PagingSource<Int, ArticleWithFeed> { ): 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? { suspend fun findArticleById(id: Int): ArticleWithFeed? {
return articleDao.queryById(id) return articleDao.queryById(id)
} }
suspend fun subscribe(feed: Feed, articles: List<Article>) { fun peekWork(): String {
val feedId = feedDao.insert(feed).toInt() return workManager.getWorkInfosByTag("sync").get().size.toString()
articleDao.insertList(articles.map { }
it.copy(feedId = feedId)
}) 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() return accountDao.queryAll().isEmpty()
} }
suspend fun addDefaultAccount(): Int { suspend fun addDefaultAccount(): Account {
return accountDao.insert( return Account(
Account( name = "Reader You",
name = "Feeds", type = Account.Type.LOCAL,
type = Account.Type.LOCAL, ).apply {
) id = accountDao.insert(this).toInt()
).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 { try {
val groupWithFeedList = opmlLocalDataSource.parseFileInputStream(inputStream) val groupWithFeedList = opmlLocalDataSource.parseFileInputStream(inputStream)
groupWithFeedList.forEach { groupWithFeed -> groupWithFeedList.forEach { groupWithFeed ->
val id = groupDao.insert(groupWithFeed.group).toInt() groupDao.insert(groupWithFeed.group)
groupWithFeed.feeds.forEach { it.groupId = id } groupWithFeed.feeds.forEach { it.groupId = groupWithFeed.group.id }
feedDao.insertList(groupWithFeed.feeds) feedDao.insertList(groupWithFeed.feeds)
} }
} catch (e: Exception) { } 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 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.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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.DelicateCoroutinesApi import me.ash.reader.DataStoreKeys
import kotlinx.coroutines.flow.* import me.ash.reader.data.account.Account
import kotlinx.coroutines.sync.Mutex import me.ash.reader.dataStore
import kotlinx.coroutines.sync.withLock import me.ash.reader.get
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 javax.inject.Inject import javax.inject.Inject
@DelicateCoroutinesApi
class RssRepository @Inject constructor( class RssRepository @Inject constructor(
@ApplicationContext @ApplicationContext
private val context: Context, private val context: Context,
private val accountDao: AccountDao, private val localRssRepository: LocalRssRepository,
private val articleDao: ArticleDao, // private val feverRssRepository: FeverRssRepository,
private val feedDao: FeedDao, // private val googleReaderRssRepository: GoogleReaderRssRepository,
private val rssNetworkDataSource: RssNetworkDataSource,
private val workManager: WorkManager,
) { ) {
@Throws(Exception::class) fun get() = when (getAccountType()) {
suspend fun searchFeed(feedLink: String): FeedWithArticle { // Account.Type.LOCAL -> localRssRepository
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0 Account.Type.LOCAL -> localRssRepository
val parseRss = rssNetworkDataSource.parseRss(feedLink) // Account.Type.FEVER -> feverRssRepository
val feed = Feed( // Account.Type.GOOGLE_READER -> googleReaderRssRepository
name = parseRss.title!!, else -> throw IllegalStateException("Unknown account type: ${getAccountType()}")
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 parseDescriptionContent(link: String, content: String): String { private fun getAccountType(): Int = context.dataStore.get(DataStoreKeys.CurrentAccountType)!!
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()
}
)
}
}
}
@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 org.xmlpull.v1.XmlPullParserException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.*
import javax.inject.Inject import javax.inject.Inject
class OpmlLocalDataSource @Inject constructor( class OpmlLocalDataSource @Inject constructor(
@ -42,9 +43,10 @@ class OpmlLocalDataSource @Inject constructor(
Log.i("RLog", "rss: ${title} , ${xmlUrl}") Log.i("RLog", "rss: ${title} , ${xmlUrl}")
groupWithFeedList.last().feeds.add( groupWithFeedList.last().feeds.add(
Feed( Feed(
id = UUID.randomUUID().toString(),
name = title, name = title,
url = xmlUrl, url = xmlUrl,
groupId = 0, groupId = UUID.randomUUID().toString(),
accountId = accountId, accountId = accountId,
) )
) )
@ -54,6 +56,7 @@ class OpmlLocalDataSource @Inject constructor(
groupWithFeedList.add( groupWithFeedList.add(
GroupWithFeed( GroupWithFeed(
group = Group( group = Group(
id = UUID.randomUUID().toString(),
name = title, name = title,
accountId = accountId, accountId = accountId,
), ),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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