diff --git a/.gitignore b/.gitignore index 6e36aaaf4..c286237ab 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,16 @@ out/ ### VS Code ### .vscode/ -signature \ No newline at end of file +signature + +AGENTS.md +.dsh/ + +# Android Studio (additional) +.kotlin/ +*.hprof +.externalNativeBuild/ +*.aab + +apk/ +.rycheck/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9551c106..1e07dd08c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,8 +42,8 @@ android { applicationId = "me.ash.reader" minSdk = 26 targetSdk = 34 - versionCode = 47 - versionName = "0.16.2" + versionCode = 48 + versionName = "0.16.3" buildConfigField( "String", diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json new file mode 100644 index 000000000..1e4320200 --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json @@ -0,0 +1,421 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "58df28e1dcfc71ba77e2c5e65b2e3479", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, `titleDisplayMode` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "titleDisplayMode", + "columnName": "titleDisplayMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '58df28e1dcfc71ba77e2c5e65b2e3479')" + ] + } +} \ No newline at end of file diff --git a/app/src/googlePlay/AndroidManifest.xml b/app/src/googlePlay/AndroidManifest.xml index 625c9a7e3..5c76d7345 100644 --- a/app/src/googlePlay/AndroidManifest.xml +++ b/app/src/googlePlay/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11fb45d60..75f7d8ea2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + , ) { val diffMap = mutableStateMapOf() + /** 当前处于 UI overlay"已读"(灰色)状态的文章 id(完整 db id)。 */ + fun overlayReadIds(): Set = + diffMap.filterValues { !it.isUnread }.keys.toSet() + + + /** 当前账户的 RSS 服务(延迟解析,避免与 RssService -> services -> DiffMapHolder 的构造循环) */ + private fun currentRssRepository(): AbstractRssRepository = rssServiceProvider.get().get() + private val pendingSyncDiffs = mutableStateMapOf() private val syncedDiffs = mutableMapOf() @@ -71,11 +88,17 @@ class DiffMapHolder @Inject constructor( applicationScope.launch { accountService.currentAccountFlow.mapNotNull { it }.collect { account -> val previousAccount = currentAccount - if (previousAccount != null && previousAccount != account) { + // 只在真正切换账户(id 变化)时清理/重建 overlay; + // 同一账户的字段更新(如同步结束时的 updateAt)会令 AccountDao flow + // 重发射,若在此清理会把灰色已读 overlay 清空,导致刷新结束后 + // 已读文章在列表里退回未读。 + if (previousAccount != null && previousAccount.id != account.id) { cleanup(previousAccount) } currentAccount = account - init(account) + if (previousAccount == null || previousAccount.id != account.id) { + init(account) + } } } } @@ -199,18 +222,35 @@ class DiffMapHolder @Inject constructor( fun commitDiffsToDb() { applicationScope.launch(ioDispatcher) { - val markAsReadArticles = diffMap.filter { !it.value.isUnread }.map { it.key }.toSet() - val markAsUnreadArticles = diffMap.filter { it.value.isUnread }.map { it.key }.toSet() - clearDiffs() - rssService.get().batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) - rssService.get().batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) + commitDiffsNow() + } + } + + /** + * 将当前 diff 落库并清理 overlay。 + * 先完成数据库写入、再清除内存 overlay,避免 UI 短暂回跳为未读; + * 调用方可 suspend 等待完成后再启动同步,保证同步快照包含最新已读状态。 + */ + suspend fun commitDiffsNow() { + val markAsReadArticles = diffMap.filter { !it.value.isUnread }.map { it.key }.toSet() + val markAsUnreadArticles = diffMap.filter { it.value.isUnread }.map { it.key }.toSet() + val repository = currentRssRepository() + if (markAsReadArticles.isNotEmpty()) { + repository.batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) + } + if (markAsUnreadArticles.isNotEmpty()) { + repository.batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) } + // 落库完成后再清 overlay/缓存 + clearDiffs() } private fun writeDiffsToCache() { + // 先同步取快照,避免调用方随后 clear diffMap 导致异步序列化拿到空 map + val snapshot = diffMap.toMap() applicationScope.launch(ioDispatcher) { try { - val tmpJson = gson.toJson(diffMap) + val tmpJson = gson.toJson(snapshot) userCacheDir.mkdirs() cacheFile.createNewFile() if (cacheFile.exists() && cacheFile.canWrite()) { @@ -231,7 +271,7 @@ class DiffMapHolder @Inject constructor( val markAsUnreadArticles = toBeSync.filter { it.value.isUnread }.map { it.key }.toSet() - val rssService = rssService.get() + val rssService = currentRssRepository() val synced = supervisorScope { val read = async { diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt index 7405e3313..a1ed6be2c 100644 --- a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt +++ b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt @@ -35,6 +35,12 @@ data class Feed( val isFullContent: Boolean = false, @ColumnInfo(defaultValue = "0") val isBrowser: Boolean = false, + /** + * 阅读页是否显示文章标题(大标题区)。 + * 0 = 跟随全局默认设置,1 = 始终显示,2 = 始终隐藏。 + */ + @ColumnInfo(defaultValue = "0") + val titleDisplayMode: Int = 0, @Ignore val important: Int = 0 ) { constructor( @@ -46,7 +52,8 @@ data class Feed( accountId: Int, isNotification: Boolean, isFullContent: Boolean, - isBrowser: Boolean + isBrowser: Boolean, + titleDisplayMode: Int = 0 ) : this( id = id, name = name, @@ -57,6 +64,16 @@ data class Feed( isNotification = isNotification, isFullContent = isFullContent, isBrowser = isBrowser, + titleDisplayMode = titleDisplayMode, important = 0 ) + + companion object { + /** 阅读页标题显示:跟随全局默认设置 */ + const val TITLE_DISPLAY_FOLLOW_DEFAULT = 0 + /** 阅读页标题显示:始终显示 */ + const val TITLE_DISPLAY_SHOW = 1 + /** 阅读页标题显示:始终隐藏 */ + const val TITLE_DISPLAY_HIDE = 2 + } } diff --git a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt index 48edce034..00a28dc58 100644 --- a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt +++ b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt @@ -84,6 +84,19 @@ interface FeedDao { isNotification: Boolean, ) + @Query( + """ + UPDATE feed SET titleDisplayMode = :titleDisplayMode + WHERE accountId = :accountId + AND groupId = :groupId + """ + ) + suspend fun updateTitleDisplayModeByGroupId( + accountId: Int, + groupId: String, + titleDisplayMode: Int, + ) + @Query( """ SELECT * FROM feed @@ -189,6 +202,7 @@ interface FeedDao { isNotification = existing.isNotification, isFullContent = existing.isFullContent, isBrowser = existing.isBrowser, + titleDisplayMode = existing.titleDisplayMode, ) if (updated == existing) { null diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 32f66c7ba..aa9b0f21b 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -9,9 +9,12 @@ import com.rometools.rome.feed.synd.SyndFeed import java.util.Date import java.util.UUID import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn +import me.ash.reader.domain.data.DiffMapHolder import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.article.ArchivedArticle import me.ash.reader.domain.model.article.Article @@ -39,8 +42,30 @@ abstract class AbstractRssRepository( private val dispatcherIO: CoroutineDispatcher, private val dispatcherDefault: CoroutineDispatcher, private val accountService: AccountService, + protected val diffMapHolder: DiffMapHolder, ) { + /** 同步进度上报(0-100),由 SyncWorker 挂载,用于刷新进度显示。 */ + @Volatile + var onSyncProgress: ((Int) -> Unit)? = null + + @Volatile + private var lastSyncProgress = -1 + + /** 在每次同步开始时调用,重置进度单调基线。 */ + protected fun beginSyncProgress() { + lastSyncProgress = -1 + } + + /** 进度只增不减,避免并发阶段上报造成百分比回退。 */ + protected fun reportProgress(progress: Int) { + val clamped = progress.coerceIn(0, 100) + if (clamped >= lastSyncProgress) { + lastSyncProgress = clamped + onSyncProgress?.invoke(clamped) + } + } + open val importSubscription: Boolean = true open val addSubscription: Boolean = true open val moveSubscription: Boolean = true @@ -149,9 +174,8 @@ abstract class AbstractRssRepository( articleDao.markAsStarredByArticleId(accountId, articleId, isStarred) } - suspend fun clearKeepArchivedArticles(): List
{ - val accountId = accountService.getCurrentAccountId() - val currentAccount = accountService.getCurrentAccount() + suspend fun clearKeepArchivedArticles(accountId: Int): List
{ + val currentAccount = accountService.getAccountById(accountId)!! val keepArchived = currentAccount.keepArchived if (keepArchived != KeepArchivedPreference.Always) { val archivedArticles = @@ -186,23 +210,30 @@ abstract class AbstractRssRepository( } fun initSync() { - accountService.getCurrentAccount().let { - val syncOnStart = it.syncOnStart.value - if (syncOnStart) { - doSyncOneTime(it.id!!) - } - if (it.syncInterval.value != SyncIntervalPreference.Manually.value) { - SyncWorker.enqueuePeriodicWork(account = it, workManager = workManager) - WidgetUpdateWorker.enqueuePeriodicWork( - workManager = workManager, - syncInterval = it.syncInterval, - syncOnlyWhenCharging = it.syncOnlyWhenCharging, - syncOnlyOnWiFi = it.syncOnlyOnWiFi, - ) - } else { - SyncWorker.cancelPeriodicWork(workManager) - WidgetUpdateWorker.cancelPeriodicWork(workManager) + val accounts = runBlocking { accountService.getAccounts().first() } + accounts.forEach { account -> + if (account.id == accountService.getCurrentAccountId()) { + if (account.syncOnStart.value) { + doSyncOneTime(account.id!!) + } } + reschedulePeriodicWork(account) + } + } + + /** 按账户的同步设置重排(或取消)周期性同步任务,设置变更后应立即调用以生效 */ + fun reschedulePeriodicWork(account: Account = accountService.getCurrentAccount()) { + if (account.syncInterval.value != SyncIntervalPreference.Manually.value) { + SyncWorker.enqueuePeriodicWork(account = account, workManager = workManager) + WidgetUpdateWorker.enqueuePeriodicWork( + workManager = workManager, + syncInterval = account.syncInterval, + syncOnlyWhenCharging = account.syncOnlyWhenCharging, + syncOnlyOnWiFi = account.syncOnlyOnWiFi, + ) + } else { + SyncWorker.cancelPeriodicWork(workManager) + WidgetUpdateWorker.cancelPeriodicWork(workManager) } } @@ -394,6 +425,14 @@ abstract class AbstractRssRepository( feedDao.updateIsBrowserByGroupId(accountService.getCurrentAccountId(), group.id, isBrowser) } + suspend fun groupTitleDisplay(group: Group, titleDisplayMode: Int) { + feedDao.updateTitleDisplayModeByGroupId( + accountService.getCurrentAccountId(), + group.id, + titleDisplayMode, + ) + } + suspend fun groupAllowNotification(group: Group, isNotification: Boolean) { feedDao.updateIsNotificationByGroupId( accountService.getCurrentAccountId(), diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index 91de4281e..ff9521db0 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -14,6 +14,7 @@ import kotlin.collections.set import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.coroutineScope import me.ash.reader.R +import me.ash.reader.domain.data.DiffMapHolder import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.account.AccountType import me.ash.reader.domain.model.account.security.FeverSecurityKey @@ -52,6 +53,7 @@ constructor( @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, workManager: WorkManager, private val accountService: AccountService, + diffMapHolder: DiffMapHolder, ) : AbstractRssRepository( articleDao, @@ -63,6 +65,7 @@ constructor( ioDispatcher, defaultDispatcher, accountService, + diffMapHolder, ) { override val importSubscription: Boolean = false @@ -149,6 +152,7 @@ constructor( groupId: String?, ): ListenableWorker.Result = coroutineScope { try { + beginSyncProgress() val preTime = System.currentTimeMillis() val preDate = Date(preTime) val account = accountService.getAccountById(accountId)!! @@ -168,6 +172,7 @@ constructor( ) } ?: emptyList() groupDao.insertOrUpdate(groups) + reportProgress(10) // 2. Fetch the Fever feeds val feedsBody = feverAPI.getFeeds() @@ -213,11 +218,13 @@ constructor( .map { it.copy(icon = rssHelper.queryRssIconLink(it.url)) } .toTypedArray() ) + reportProgress(25) // 3. Fetch the Fever articles (up to unlimited counts) val allArticles = mutableListOf
() var lastSeenId = account.lastArticleId?.dollarLast() ?: "" + var pageNo = 0 while (true) { val itemsBody = feverAPI.getItemsSince(lastSeenId) @@ -251,6 +258,9 @@ constructor( } allArticles.addAll(articlesFromBatch) + pageNo++ + // 翻页拉取进度(批次估算,最多按 40 页封顶) + reportProgress((30 + 55 * pageNo / 40).coerceAtMost(85)) lastSeenId = fetchedItems.lastOrNull()?.id ?: break @@ -258,13 +268,18 @@ constructor( break } } + reportProgress(85) if (allArticles.isNotEmpty()) { - articleDao.insert(*allArticles.toTypedArray()) + // 已存在行不覆盖(保留本地已读/星标状态),只入库新增文章 + val existingArticleIds = + articleDao.queryMetadataAll(accountId).map { it.id }.toSet() + articleDao.insertOnConflictIgnore(*allArticles.toTypedArray()) + val newArticles = allArticles.filterNot { existingArticleIds.contains(it.id) } val notificationFeeds = feedDao.queryNotificationEnabled(accountId).associateBy { it.id } val notificationFeedIds = notificationFeeds.keys - allArticles + newArticles .fastFilter { it.isUnread && it.feedId in notificationFeedIds } .groupBy { it.feedId } .mapKeys { (feedId, _) -> notificationFeeds[feedId]!! } @@ -275,12 +290,31 @@ constructor( val unreadArticleIds = feverAPI.getUnreadItems().unread_item_ids?.split(",") val starredArticleIds = feverAPI.getSavedItems().saved_item_ids?.split(",") val articleMeta = articleDao.queryMetadataAll(accountId) - for (meta: ArticleMeta in articleMeta) { + val metaTotal = articleMeta.size.coerceAtLeast(1) + for ((metaIndex, meta: ArticleMeta) in articleMeta.withIndex()) { + if (metaIndex % 200 == 0) { + reportProgress(88 + (10 * metaIndex / metaTotal).coerceAtMost(10)) + } val articleId = meta.id.dollarLast() val shouldBeUnread = unreadArticleIds?.contains(articleId) val shouldBeStarred = starredArticleIds?.contains(articleId) - if (meta.isUnread != shouldBeUnread) { - articleDao.markAsReadByArticleId(accountId, meta.id, shouldBeUnread ?: true) + if (shouldBeUnread != null && meta.isUnread != shouldBeUnread) { + if (!meta.isUnread) { + // 本地已读但远端未读:推读远端,本地保持已读(避免同步把已读打回未读) + runCatching { + feverAPI.markItem( + status = FeverDTO.StatusEnum.Read, + id = articleId, + ) + } + } else { + // 本地未读且远端已读:本地标为已读; + // 但跳过仍处于 UI"灰色已读 overlay"的文章(打开后未落库), + // 保持灰色直到用户离开列表页统一落库 + if (meta.id !in diffMapHolder.overlayReadIds()) { + articleDao.markAsReadByArticleId(accountId, meta.id, false) + } + } } if (meta.isStarred != shouldBeStarred) { articleDao.markAsStarredByArticleId( @@ -290,6 +324,7 @@ constructor( ) } } + reportProgress(100) // Remove orphaned groups and feeds, after synchronizing the starred/un-starred val groupIds = groups.map { it.id } @@ -321,7 +356,8 @@ constructor( // withContext(mainDispatcher) { // context.showToast(e.message) // } - ListenableWorker.Result.failure() + // 返回 retry 与其他账户类型保持一致,由 SyncWorker 统一控制重试上限 + ListenableWorker.Result.retry() } } diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt index 1c9a6e29b..cffdec33a 100644 --- a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -10,6 +10,7 @@ import com.rometools.rome.feed.synd.SyndFeed import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Calendar import java.util.Date +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.collections.chunked import kotlin.collections.toSet @@ -27,6 +28,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import me.ash.reader.R import me.ash.reader.domain.data.SyncLogger +import me.ash.reader.domain.data.DiffMapHolder import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.account.AccountType import me.ash.reader.domain.model.account.AccountType.Companion.FreshRSS @@ -77,6 +79,7 @@ constructor( private val workManager: WorkManager, private val accountService: AccountService, private val syncLogger: SyncLogger, + diffMapHolder: DiffMapHolder, ) : AbstractRssRepository( articleDao, @@ -88,6 +91,7 @@ constructor( ioDispatcher, defaultDispatcher, accountService, + diffMapHolder, ) { override val importSubscription: Boolean = false @@ -225,6 +229,7 @@ constructor( feedId: String?, groupId: String?, ): ListenableWorker.Result { + beginSyncProgress() return if (feedId != null) { syncFeed(accountId, feedId) } else { @@ -267,6 +272,7 @@ constructor( } val googleReaderAPI = getGoogleReaderAPI() googleReaderAPI.refreshCredentialsIfNeeded() + val planner = SyncProgressPlanner(::reportProgress) val lastMonthAt = Calendar.getInstance() .apply { @@ -277,20 +283,28 @@ constructor( .time / 1000 val remoteUnreadIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getUnreadItemIds(continuationId = it) } + fetchItemIdsAndContinue( + // 每次分页网络往返 +1%(页数未知时用封顶预算,见 SyncProgressPlanner) + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getUnreadItemIds(continuationId = it) } .map { it.shortId } .toSet() } val remoteStarredIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getStarredItemIds(continuationId = it) } + fetchItemIdsAndContinue( + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() } val isFreshRss = account.type.id == FreshRSS.id val remoteReadIds = async { - fetchItemIdsAndContinue { + // 已读 id 列表通常最大(近一月),同样按页计步 + fetchItemIdsAndContinue( + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getReadItemIds( since = lastMonthAt, continuationId = it, @@ -348,8 +362,12 @@ constructor( } launch { + // 远端已读 ⇒ 本地标已读;但跳过仍处于 UI"灰色已读 overlay"的文章 + // (打开后尚未 commit 落库),保持灰色状态直到用户离开列表页再统一落库 + val overlayReadIds = + diffMapHolder.overlayReadIds().map { it.dollarLast() }.toSet() val toBeReadLocal = - remoteReadIds.await().intersect(localUnreadIds).map { + remoteReadIds.await().intersect(localUnreadIds - overlayReadIds).map { accountId spacerDollar it } toBeReadLocal.chunked(1000).forEach { @@ -362,30 +380,24 @@ constructor( } launch { - val toBeUnreadLocal = - localReadIds.intersect(remoteUnreadIds.await()).map { - accountId spacerDollar it + // 本地已读但远端仍为未读:把已读推送给远端,本地保持已读。 + // 不再把本地已读强制改回未读(远端快照早于本机操作时会导致已读丢失)。 + // 推送失败无需处理:下一次同步仍会命中交集并重试,直到远端收敛。 + val toBePushedRead = + localReadIds.intersect(remoteUnreadIds.await()).toList() + // 历史积压时推送是同步里最耗时的网络步骤,每批推送完成报一步 + toBePushedRead.chunked(500).forEach { + runCatching { + googleReaderAPI.editTag( + itemIds = it, + mark = GoogleReaderAPI.Stream.Read.tag, + unmark = null, + ) } - toBeUnreadLocal.chunked(1000).forEach { - articleDao.markAsReadByIdSet( - accountId = accountId, - ids = it.toSet(), - isUnread = true, - ) + planner.phase2Step() } } - // - // launch { - // val toBeReadRemote = localReadIds.intersect(remoteUnreadIds.await()) - // if (toBeReadRemote.isNotEmpty()) { - // googleReaderAPI.editTag( - // itemIds = toBeReadRemote.toList(), - // mark = GoogleReaderAPI.Stream.Read.tag, - // ) - // } - // } - // 2. Fetch folder and subscription list val groupWithFeedsMap = async { val subscriptionList = googleReaderAPI.getSubscriptionList() @@ -461,32 +473,43 @@ constructor( .also { feedDao.update(*it.toTypedArray()) } } - groupDao.insertOrUpdate(remoteGroups.await()) + val groupsResolved = remoteGroups.await() + planner.phase1Step() // 订阅列表:1 次网络请求 + groupDao.insertOrUpdate(groupsResolved) feedDao.insertOrUpdate(remoteFeeds.await()) + // 进入阶段 2:此时内容批数(deferredList.size,每批 100 条)与推送批数 + // (每批 500 条)都已知,剩余预算按这些真实单元 + 2 段收尾平均分配 + val pushChunkCount = + (localReadIds.intersect(remoteUnreadIds.await()).size + 499) / 500 + planner.beginPhase2(deferredList.size + pushChunkCount + 2) + val notificationFeeds = feedDao.queryNotificationEnabled(accountId).associateBy { it.id } val notificationFeedIds = notificationFeeds.keys val articlesToNotify = mutableListOf
() - if (deferredList.isNotEmpty()) { - launch { - whileSelect { - for (deferred in deferredList) { - deferred.onAwait { - articleDao.insertList(it) - articlesToNotify.addAll( - it.fastFilter { - it.isUnread && notificationFeedIds.contains(it.feedId) - } - ) - deferredList.remove(deferred) - deferredList.isNotEmpty() + val contentJob = + if (deferredList.isNotEmpty()) { + val job = + launch { + whileSelect { + for (deferred in deferredList) { + deferred.onAwait { + planner.phase2Step() + articleDao.insertList(it) + articlesToNotify.addAll( + it.fastFilter { + it.isUnread && notificationFeedIds.contains(it.feedId) + } + ) + deferredList.remove(deferred) + deferredList.isNotEmpty() + } } } } - } - .invokeOnCompletion { + job.invokeOnCompletion { launch { articlesToNotify .groupBy { it.feedId } @@ -496,7 +519,14 @@ constructor( } } } - } + job + } else { + // 没有新文章内容要抓取 + null + } + + // 等新文章内容全部抓取完成后进入收尾 + contentJob?.join() // 8. Remove orphaned groups and feeds, after synchronizing the // starred/un-starred @@ -509,6 +539,8 @@ constructor( .filter { it.id !in remoteFeeds.await().map { feed -> feed.id } } .forEach { super.deleteFeed(it, true) } + planner.phase2Step() // 收尾单元 1:孤儿清理完成 + planner.phase2Step() // 收尾单元 2:全部完成 → 100% accountService.update(account.copy(updateAt = Date())) ListenableWorker.Result.success() } catch (e: Exception) { @@ -535,6 +567,7 @@ constructor( "account type is invalid" } val googleReaderAPI = getGoogleReaderAPI() + val planner = SyncProgressPlanner(::reportProgress) val feed = feedDao.queryById(feedId)!! @@ -559,7 +592,9 @@ constructor( val localIds = (localReadIds + localUnreadIds).toSet() val remoteUnreadIds = async { - fetchItemIdsAndContinue { + fetchItemIdsAndContinue( + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), filterRead = true, @@ -571,7 +606,10 @@ constructor( } val remoteAllIds = async { - fetchItemIdsAndContinue { + // 全部 id 列表一般最大,占大头进度;同样按页计步 + fetchItemIdsAndContinue( + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), filterRead = false, @@ -583,12 +621,15 @@ constructor( } val remoteStarredIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getStarredItemIds(continuationId = it) } + fetchItemIdsAndContinue( + onPage = { planner.phase1Step() }, + ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() } val toFetch = remoteAllIds.await() - localIds + val contentUnits = (toFetch.size + 99) / 100 val items = fetchItemsContents( @@ -599,6 +640,12 @@ constructor( starredIds = remoteStarredIds.await(), ) + // 内容整体拉完,一次性走完内容单元;剩余预算按推送批数 + 2 段收尾分配 + val pushChunkCount = + (localReadIds.intersect(remoteUnreadIds.await()).size + 499) / 500 + planner.beginPhase2(contentUnits + pushChunkCount + 2) + planner.phase2Step(units = contentUnits) + if (feed.isNotification) { val articlesToNotify = items.fastFilter { it.isUnread } notificationHelper.notify(feed, articlesToNotify) @@ -606,7 +653,10 @@ constructor( launch { val remoteReadIds = remoteAllIds.await() - remoteUnreadIds.await() - val toBeReadIds = remoteReadIds.intersect(localUnreadIds) + // 跳过仍处于 UI"灰色已读 overlay"的文章,保持灰色直到离开列表页统一落库 + val overlayReadIds = + diffMapHolder.overlayReadIds().map { it.dollarLast() }.toSet() + val toBeReadIds = remoteReadIds.intersect(localUnreadIds - overlayReadIds) toBeReadIds .map { it.dbId(accountId) } @@ -621,17 +671,20 @@ constructor( } launch { - val toBeUnreadIds = localReadIds.intersect(remoteUnreadIds.await()) - toBeUnreadIds - .map { it.dbId(accountId) } - .chunked(1000) - .forEach { - articleDao.markAsReadByIdSet( - accountId = accountId, - ids = it.toSet(), - isUnread = true, + // 本地已读但远端仍为未读:推读远端,本地保持已读(参见整账户同步的说明) + val chunks = + localReadIds.intersect(remoteUnreadIds.await()).map { it.dollarLast() } + .chunked(500) + chunks.forEach { + runCatching { + googleReaderAPI.editTag( + itemIds = it, + mark = GoogleReaderAPI.Stream.Read.tag, + unmark = null, ) } + planner.phase2Step() + } } launch { @@ -664,18 +717,26 @@ constructor( } articleDao.insert(*items.toTypedArray()) + planner.phase2Step() // 收尾单元 1:入库完成 + planner.phase2Step() // 收尾单元 2:全部完成 → 100% Timber.i("onCompletion: ${System.currentTimeMillis() - preTime}") ListenableWorker.Result.success() } private suspend fun fetchItemIdsAndContinue( - getItemIdsFunc: suspend (continuationId: String?) -> GoogleReaderDTO.ItemIds? + onPage: ((page: Int) -> Unit)? = null, + getItemIdsFunc: suspend (continuationId: String?) -> GoogleReaderDTO.ItemIds?, ): MutableList { + var pageNo = 0 var result = getItemIdsFunc(null) + pageNo++ + onPage?.invoke(pageNo) val ids = result?.itemRefs?.mapNotNull { it.id }?.toMutableList() ?: return mutableListOf() while (result != null && result.continuation != null) { result = getItemIdsFunc(result.continuation) + pageNo++ + onPage?.invoke(pageNo) result?.itemRefs?.mapNotNull { it.id }?.let { ids.addAll(it) } } return ids @@ -865,3 +926,67 @@ constructor( ) } } + +/** + * 动态预算进度计划器(GR 同步用)。 + * + * 进度 = 已完成请求单元 / 估算总单元,每一步都对应真实完成的网络/DB 工作: + * - 阶段 1(id 列表分页 + 订阅列表,总页数未知):每次网络往返 +1%, + * 封顶 [PHASE1_BUDGET];页数超过封顶时冻结在封顶值,等阶段 1 结束重分配。 + * - 阶段 2(新文章内容、已读推送、收尾):此时总量已全部已知,把剩余预算 + * 按已知的请求单元数平均分配,保证 100% 落在同步真实完成的时刻, + * 不做任何假进度。 + */ +private class SyncProgressPlanner( + private val report: (Int) -> Unit, +) { + /** 阶段 1 每完成一个单元(id 列表一页 / 订阅列表一次)的百分比步长 */ + private val phase1UnitPercent = 2 + + /** 阶段 1 封顶:最多 48 个单元(含订阅列表)× 每单元 2%;页数超过封顶时冻结,阶段 1 结束重分配 */ + private val phase1Budget = 96 + private val phase1Units = AtomicInteger(0) + private val phase2DoneUnits = AtomicInteger(0) + @Volatile + private var phase2 = false + @Volatile + private var phase2TotalUnits = 1 + @Volatile + private var current = 0 + + /** 阶段 1:完成一次网络往返(id 列表一页 = 1 单元,订阅列表一次 = 1 单元) */ + fun phase1Step(units: Int = 1) { + if (phase2) return + val done = phase1Units.addAndGet(units) + publish(minOf(phase1Budget, done * phase1UnitPercent)) + } + + /** 进入阶段 2:剩余预算按 [totalUnits] 个已知工作单元平均分配 */ + fun beginPhase2(totalUnits: Int) { + if (phase2) return + phase2TotalUnits = totalUnits.coerceAtLeast(1) + phase2 = true + publishPhase2() + } + + /** 阶段 2:完成 [units] 个工作单元(一批内容 / 一批推送 / 一段收尾) */ + fun phase2Step(units: Int = 1) { + phase2DoneUnits.addAndGet(units) + publishPhase2() + } + + private fun publishPhase2() { + if (!phase2) return + val fraction = + (phase2DoneUnits.get().toDouble() / phase2TotalUnits).coerceAtMost(1.0) + publish(current + ((100 - current) * fraction).toInt()) + } + + @Synchronized + private fun publish(value: Int) { + if (value > current) { + current = value + report(value) + } + } +} diff --git a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt index 509f7b547..f909c1a84 100644 --- a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt @@ -5,6 +5,7 @@ import androidx.work.ListenableWorker import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Date +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -14,6 +15,7 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import me.ash.reader.domain.data.SyncLogger +import me.ash.reader.domain.data.DiffMapHolder import me.ash.reader.domain.model.account.AccountType import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.feed.FeedWithArticle @@ -42,6 +44,7 @@ constructor( private val workManager: WorkManager, private val accountService: AccountService, private val syncLogger: SyncLogger, + diffMapHolder: DiffMapHolder, ) : AbstractRssRepository( articleDao, @@ -53,6 +56,7 @@ constructor( ioDispatcher, defaultDispatcher, accountService, + diffMapHolder, ) { override suspend fun sync( @@ -61,6 +65,7 @@ constructor( groupId: String? ): ListenableWorker.Result = supervisorScope { return@supervisorScope runCatching { + beginSyncProgress() val preTime = System.currentTimeMillis() val preDate = Date(preTime) val currentAccount = accountService.getAccountById(accountId)!! @@ -76,6 +81,10 @@ constructor( else -> feedDao.queryAll(accountId) } + val totalFeeds = feedsToSync.size.coerceAtLeast(1) + val completedFeeds = AtomicInteger(0) + reportProgress(10) + feedsToSync .mapIndexed { _, currentFeed -> async(Dispatchers.IO) { @@ -101,11 +110,14 @@ constructor( fetchedFeed.copy(articles = newArticles, feed = currentFeed) ) } + val done = completedFeeds.incrementAndGet() + reportProgress(10 + (90 * done / totalFeeds).coerceAtMost(90)) } } } .awaitAll() + reportProgress(100) Timber.tag("RlOG").i("onCompletion: ${System.currentTimeMillis() - preTime}") accountService.update(currentAccount.copy(updateAt = Date())) ListenableWorker.Result.success() diff --git a/app/src/main/java/me/ash/reader/domain/service/ReaderWorker.kt b/app/src/main/java/me/ash/reader/domain/service/ReaderWorker.kt index 4c641f477..ba7a831c4 100644 --- a/app/src/main/java/me/ash/reader/domain/service/ReaderWorker.kt +++ b/app/src/main/java/me/ash/reader/domain/service/ReaderWorker.kt @@ -22,20 +22,36 @@ constructor( @Assisted workerParams: WorkerParameters, private val rssService: RssService, private val cacheHelper: ReaderCacheHelper, + private val accountService: AccountService, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { + val accountId = inputData.getInt("accountId", -1) + val accountTypeId = if (accountId != -1) { + accountService.getAccountById(accountId)?.type?.id ?: return Result.failure() + } else { + // 兼容未传 accountId 的旧调用方(例如已入队的旧任务) + accountService.getCurrentAccount().type.id + } + val semaphore = Semaphore(2) val deferredList = withContext(Dispatchers.IO) { - val rssService = rssService.get() - val articleList = rssService.queryUnreadFullContentArticles() + val repo = rssService.get(accountTypeId) + val articleList = repo.queryUnreadFullContentArticles() articleList.map { async { semaphore.withPermit { cacheHelper.checkOrFetchFullContent(it) } } } } - return if (deferredList.awaitAll().any { !it }) Result.retry() else Result.success() + return when { + deferredList.awaitAll().all { it } -> Result.success() + // 达到重试上限后返回 success 放弃本轮, + // 让 POST_SYNC_WORK 唯一链正常结束(KEEP 策略下不阻塞后续同步的新链), + // 缺失的全文会在下次同步后的新链中再次尝试抓取 + runAttemptCount >= SyncWorker.MAX_RETRY_ATTEMPTS -> Result.success() + else -> Result.retry() + } } } diff --git a/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt b/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt index deedee18b..dbeb138f5 100644 --- a/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt +++ b/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt @@ -6,8 +6,13 @@ import androidx.work.* import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import me.ash.reader.domain.model.account.Account import me.ash.reader.infrastructure.rss.ReaderCacheHelper +import timber.log.Timber @HiltWorker class SyncWorker @@ -16,45 +21,99 @@ constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, private val rssService: RssService, + private val accountService: AccountService, private val readerCacheHelper: ReaderCacheHelper, private val workManager: WorkManager, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { + return try { + doWorkInternal() + } catch (ce: CancellationException) { + // 留给 WorkManager 处理协程取消,不能吃掉 + throw ce + } catch (e: Exception) { + Timber.e(e, "SyncWorker.doWork crashed") + if (runAttemptCount >= MAX_RETRY_ATTEMPTS) Result.failure() + else Result.retry() + } + } + + private val syncProgressScope = CoroutineScope(Dispatchers.Default) + + private suspend fun doWorkInternal(): Result { val data = inputData val accountId = data.getInt("accountId", -1) - require(accountId != -1) + if (accountId == -1) return Result.failure() val feedId = data.getString("feedId") val groupId = data.getString("groupId") - return rssService - .get() - .sync(accountId = accountId, feedId = feedId, groupId = groupId) - .also { - rssService.get().clearKeepArchivedArticles().forEach { - readerCacheHelper.deleteCacheFor(articleId = it.id) + // 按任务携带的 accountId 分发到对应类型的服务, + // 避免周期任务在切换账户后仍按“当前账户”类型分发而永久失败 + val account = accountService.getAccountById(accountId) ?: return Result.failure() + val service = rssService.get(account.type.id) + + // 挂载进度回调:服务内 reportProgress -> WorkManager setProgress, + // 供下拉刷新 UI 读取并显示百分比(工作结束后务必清理) + // 注意:CoroutineWorker.setProgress 是 suspend 函数,需在协程中调用 + service.onSyncProgress = { progress -> + syncProgressScope.launch { + runCatching { setProgress(workDataOf("syncProgress" to progress)) } + } + } + return try { + service + .sync(accountId = accountId, feedId = feedId, groupId = groupId) + .let { result -> + // 达到重试上限后放弃本轮并返回 failure: + // 周期任务会被 WorkManager 重置(resetPeriodic), + // 下一个同步间隔照常运行,而不是陷入无限指数退避 + if (result is Result.Retry && runAttemptCount >= MAX_RETRY_ATTEMPTS) { + Timber.e("Sync failed after $runAttemptCount attempts, giving up this round") + Result.failure() + } else result } - workManager - .beginUniqueWork( - uniqueWorkName = POST_SYNC_WORK_NAME, - existingWorkPolicy = ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder() - .addTag(READER_TAG) - .addTag(ONETIME_WORK_TAG) - .setBackoffCriteria( - backoffPolicy = BackoffPolicy.EXPONENTIAL, - backoffDelay = 30, - timeUnit = TimeUnit.SECONDS, - ) - .build(), + .let { result -> + if (result is Result.Success) { + runCatching { doPostSync(service, accountId) } + .onFailure { Timber.e(it, "Failed to run post-sync tasks") } + } + result + } + } finally { + service.onSyncProgress = null + } + } + + private suspend fun doPostSync(service: AbstractRssRepository, accountId: Int) { + service.clearKeepArchivedArticles(accountId).forEach { + readerCacheHelper.deleteCacheFor(articleId = it.id) + } + workManager + .beginUniqueWork( + uniqueWorkName = POST_SYNC_WORK_NAME, + existingWorkPolicy = ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder() + .addTag(READER_TAG) + .addTag(ONETIME_WORK_TAG) + .setInputData(workDataOf("accountId" to accountId)) + .setBackoffCriteria( + backoffPolicy = BackoffPolicy.EXPONENTIAL, + backoffDelay = 30, + timeUnit = TimeUnit.SECONDS, ) - .then(OneTimeWorkRequestBuilder().build()) - .enqueue() - } + .build(), + ) + .then(OneTimeWorkRequestBuilder().build()) + .enqueue() } companion object { private const val SYNC_WORK_NAME_PERIODIC = "ReadYou" + + // 最大退避重试次数(首次运行 runAttemptCount 为 0, + // 之后每次退避重试 +1),超过后本轮放弃,等待下个周期 + const val MAX_RETRY_ATTEMPTS = 2 @Deprecated("do not use") private const val READER_WORK_NAME_PERIODIC = "FETCH_FULL_CONTENT_PERIODIC" private const val POST_SYNC_WORK_NAME = "POST_SYNC_WORK" @@ -84,53 +143,46 @@ constructor( .addTag(SYNC_TAG) .addTag(ONETIME_WORK_TAG) .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build(), ) .enqueue() } fun enqueuePeriodicWork(account: Account, workManager: WorkManager) { - val syncInterval = account.syncInterval - val syncOnlyWhenCharging = account.syncOnlyWhenCharging - val syncOnlyOnWiFi = account.syncOnlyOnWiFi - val workState = - workManager - .getWorkInfosForUniqueWork(SYNC_WORK_NAME_PERIODIC) - .get() - .firstOrNull() - ?.state - - val policy = - if (workState == WorkInfo.State.ENQUEUED || workState == WorkInfo.State.RUNNING) - ExistingPeriodicWorkPolicy.UPDATE - else ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE - workManager.enqueueUniquePeriodicWork( SYNC_WORK_NAME_PERIODIC, - policy, - PeriodicWorkRequestBuilder(syncInterval.value, TimeUnit.MINUTES) - .setConstraints( - Constraints.Builder() - .setRequiresCharging(syncOnlyWhenCharging.value) - .setRequiredNetworkType( - if (syncOnlyOnWiFi.value) NetworkType.UNMETERED - else NetworkType.CONNECTED - ) - .build() - ) - .setBackoffCriteria( - backoffPolicy = BackoffPolicy.EXPONENTIAL, - backoffDelay = 30, - timeUnit = TimeUnit.SECONDS, - ) - .setInputData(workDataOf("accountId" to account.id)) - .addTag(SYNC_TAG) - .addTag(PERIODIC_WORK_TAG) - .setInitialDelay(syncInterval.value, TimeUnit.MINUTES) - .build(), + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + buildPeriodicWorkRequest(account), ) - workManager.cancelUniqueWork(READER_WORK_NAME_PERIODIC) } + + private fun buildPeriodicWorkRequest( + account: Account, + ): PeriodicWorkRequest = + PeriodicWorkRequestBuilder( + account.syncInterval.value, + TimeUnit.MINUTES, + ) + .setConstraints( + Constraints.Builder() + .setRequiresCharging(account.syncOnlyWhenCharging.value) + .setRequiredNetworkType( + if (account.syncOnlyOnWiFi.value) NetworkType.UNMETERED + else NetworkType.CONNECTED + ) + .build() + ) + .setBackoffCriteria( + backoffPolicy = BackoffPolicy.EXPONENTIAL, + backoffDelay = 30, + timeUnit = TimeUnit.SECONDS, + ) + .setInputData(workDataOf("accountId" to account.id)) + .addTag(SYNC_TAG) + .addTag(PERIODIC_WORK_TAG) + .setInitialDelay(0, TimeUnit.MINUTES) + .build() } } diff --git a/app/src/main/java/me/ash/reader/domain/service/WidgetUpdateWorker.kt b/app/src/main/java/me/ash/reader/domain/service/WidgetUpdateWorker.kt index e2ed8a399..8459d3738 100644 --- a/app/src/main/java/me/ash/reader/domain/service/WidgetUpdateWorker.kt +++ b/app/src/main/java/me/ash/reader/domain/service/WidgetUpdateWorker.kt @@ -13,6 +13,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import dagger.assisted.Assisted @@ -69,9 +70,22 @@ constructor( syncOnlyWhenCharging: SyncOnlyWhenChargingPreference, syncOnlyOnWiFi: SyncOnlyOnWiFiPreference, ) { + val workState = + workManager + .getWorkInfosForUniqueWork(WORK_NAME_PERIODIC) + .get() + .firstOrNull() + ?.state + // 仅进程内正在运行时用 UPDATE 保留现场, + // 其他状态全部 CANCEL_AND_REENQUEUE 以重置 lastEnqueueTime + val policy = + if (workState == WorkInfo.State.RUNNING) + ExistingPeriodicWorkPolicy.UPDATE + else ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE + workManager.enqueueUniquePeriodicWork( WORK_NAME_PERIODIC, - ExistingPeriodicWorkPolicy.UPDATE, + policy, PeriodicWorkRequestBuilder(syncInterval.value, TimeUnit.MINUTES) .setConstraints( Constraints.Builder() diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt index b4d211baa..99fff6e95 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt @@ -1,6 +1,10 @@ package me.ash.reader.infrastructure.android import android.app.Application +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import androidx.work.WorkManager @@ -106,6 +110,25 @@ class AndroidApp : Application(), Configuration.Provider { checkUpdate() } Coil.setImageLoader(imageLoader) + registerNetworkCallback() + } + + private fun registerNetworkCallback() { + val connectivityManager = + getSystemService(ConnectivityManager::class.java) as ConnectivityManager + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + applicationScope.launch { + rssService.get().initSync() + } + } + } + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, networkCallback) } /** Override the [Configuration.Builder] to provide the [HiltWorkerFactory]. */ diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt index 7b0898dc4..6d2f4dc7b 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt @@ -63,6 +63,8 @@ constructor( } } + val inboxStyle = NotificationCompat.InboxStyle() + articles.forEach { inboxStyle.addLine(it.title) } notificationManager.notify( feed.id.hashCode(), NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE) @@ -71,9 +73,10 @@ constructor( context.resources.getQuantityText(R.plurals.unread_desc, articles.size) ) .setSmallIcon(R.drawable.ic_notification) - .setStyle(NotificationCompat.InboxStyle().setSummaryText(feed.name)) + .setStyle(inboxStyle) .setGroup(feed.id) .setGroupSummary(true) + .setAutoCancel(true) .build(), ) @@ -101,7 +104,7 @@ constructor( ) .setGroup(feed.id) notificationManager.notify( - Random().nextInt() + article.id.hashCode(), + article.id.hashCode(), builder.build(), ) } diff --git a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt index 925b345b7..9ca6de2f3 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt @@ -20,7 +20,7 @@ import java.util.* @Database( entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class], - version = 7, + version = 8, autoMigrations = [ AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 7), @@ -80,6 +80,7 @@ val allMigrations = arrayOf( MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, + MIGRATION_7_8, ) @Suppress("ClassName") @@ -159,3 +160,15 @@ object MIGRATION_4_5 : Migration(4, 5) { ) } } + +@Suppress("ClassName") +object MIGRATION_7_8 : Migration(7, 8) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + ALTER TABLE feed ADD COLUMN titleDisplayMode INTEGER NOT NULL DEFAULT 0 + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/CacheHolderModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/CacheHolderModule.kt index afd4a59aa..c1653616c 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/di/CacheHolderModule.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/di/CacheHolderModule.kt @@ -14,6 +14,7 @@ import me.ash.reader.domain.service.AccountService import me.ash.reader.infrastructure.preference.SettingsProvider import me.ash.reader.infrastructure.rss.ReaderCacheHelper import me.ash.reader.infrastructure.rss.RssHelper +import javax.inject.Provider import javax.inject.Singleton @Module @@ -26,10 +27,10 @@ object CacheHolderModule { @ApplicationScope applicationScope: CoroutineScope, @IODispatcher ioDispatcher: CoroutineDispatcher, accountService: AccountService, - rssService: RssService, + rssServiceProvider: Provider, ): DiffMapHolder { return DiffMapHolder( - context = context, applicationScope, ioDispatcher, accountService, rssService + context = context, applicationScope, ioDispatcher, accountService, rssServiceProvider ) } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt index bdc2c057d..5532c4897 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt @@ -57,6 +57,8 @@ fun Preferences.toSettings(): Settings { readingTheme = ReadingThemePreference.fromPreferences(this), readingPageTonalElevation = ReadingPageTonalElevationPreference.fromPreferences(this), readingAutoHideToolbar = ReadingAutoHideToolbarPreference.fromPreferences(this), + readingTitleVisibility = ReadingTitleVisibilityPreference.fromPreferences(this), + readingOpenLinkFab = ReadingOpenLinkFabPreference.fromPreferences(this), readingTextFontSize = ReadingTextFontSizePreference.fromPreferences(this), readingTextLineHeight = ReadingTextLineHeightPreference.fromPreferences(this), readingLetterSpacing = ReadingTextLetterSpacingPreference.fromPreferences(this), diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingOpenLinkFabPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingOpenLinkFabPreference.kt new file mode 100644 index 000000000..f3d4e9b9d --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingOpenLinkFabPreference.kt @@ -0,0 +1,54 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.readingOpenLinkFab +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalReadingOpenLinkFab = + compositionLocalOf { + ReadingOpenLinkFabPreference.default + } + +/** + * 阅读页右下角"打开原文链接"悬浮按钮开关。 + * 开启时点击该按钮等同于点击文章标题区,按打开链接偏好调起浏览器。 + */ +sealed class ReadingOpenLinkFabPreference(val value: Boolean) : Preference() { + /** 显示悬浮按钮 */ + object ON : ReadingOpenLinkFabPreference(true) + /** 隐藏悬浮按钮 */ + object OFF : ReadingOpenLinkFabPreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { + context.dataStore.put(DataStoreKey.readingOpenLinkFab, value) + } + } + + companion object { + + val default = ON + val values = listOf(ON, OFF) + + fun fromPreferences(preferences: Preferences) = + when ( + preferences[DataStoreKey.keys[readingOpenLinkFab]?.key as Preferences.Key] + ) { + true -> ON + false -> OFF + else -> default + } + } +} + +operator fun ReadingOpenLinkFabPreference.not(): ReadingOpenLinkFabPreference = + when (value) { + true -> ReadingOpenLinkFabPreference.OFF + false -> ReadingOpenLinkFabPreference.ON + } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTitleVisibilityPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTitleVisibilityPreference.kt new file mode 100644 index 000000000..51272ef31 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTitleVisibilityPreference.kt @@ -0,0 +1,56 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.readingTitleVisibility +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalReadingTitleVisibility = + compositionLocalOf { + ReadingTitleVisibilityPreference.default + } + +/** + * 阅读页文章大标题的全局默认显示设置。 + * 单个订阅源/分组可通过 titleDisplayMode 覆盖该默认值。 + */ +sealed class ReadingTitleVisibilityPreference(val value: Boolean) : Preference() { + /** 显示文章标题 */ + object SHOW : ReadingTitleVisibilityPreference(true) + /** 隐藏文章标题,只显示正文 */ + object HIDE : ReadingTitleVisibilityPreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { + context.dataStore.put(DataStoreKey.readingTitleVisibility, value) + } + } + + companion object { + + val default = SHOW + val values = listOf(SHOW, HIDE) + + fun fromPreferences(preferences: Preferences) = + when ( + preferences[ + DataStoreKey.keys[readingTitleVisibility]?.key as Preferences.Key + ] + ) { + true -> SHOW + false -> HIDE + else -> default + } + } +} + +operator fun ReadingTitleVisibilityPreference.not(): ReadingTitleVisibilityPreference = + when (value) { + true -> ReadingTitleVisibilityPreference.HIDE + false -> ReadingTitleVisibilityPreference.SHOW + } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt index af4f01012..706305947 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt @@ -50,6 +50,8 @@ data class Settings( val readingTheme: ReadingThemePreference = ReadingThemePreference.default, val readingPageTonalElevation: ReadingPageTonalElevationPreference = ReadingPageTonalElevationPreference.default, val readingAutoHideToolbar: ReadingAutoHideToolbarPreference = ReadingAutoHideToolbarPreference.default, + val readingTitleVisibility: ReadingTitleVisibilityPreference = ReadingTitleVisibilityPreference.default, + val readingOpenLinkFab: ReadingOpenLinkFabPreference = ReadingOpenLinkFabPreference.default, val readingTextFontSize: Int = ReadingTextFontSizePreference.default, val readingTextLineHeight: Float = ReadingTextLineHeightPreference.default, val readingLetterSpacing: Float = ReadingTextLetterSpacingPreference.default, diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt index dd14b72d3..d0edc53f0 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt @@ -109,6 +109,8 @@ class SettingsProvider @Inject constructor( LocalReadingTheme provides settings.readingTheme, LocalReadingPageTonalElevation provides settings.readingPageTonalElevation, LocalReadingAutoHideToolbar provides settings.readingAutoHideToolbar, + LocalReadingTitleVisibility provides settings.readingTitleVisibility, + LocalReadingOpenLinkFab provides settings.readingOpenLinkFab, LocalReadingTextFontSize provides settings.readingTextFontSize, LocalReadingTextLineHeight provides settings.readingTextLineHeight, LocalReadingTextLetterSpacing provides settings.readingLetterSpacing, diff --git a/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt b/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt index 02747b198..fc4b2142a 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt @@ -7,6 +7,9 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.os.Build +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings import android.os.Parcelable import android.util.Log import android.widget.Toast @@ -205,3 +208,15 @@ fun Context.getCustomTabsPackages(): List { return@mapNotNull null }.toList() } + +/** 检查应用是否已被豁免电池优化 */ +fun Context.isIgnoringBatteryOptimizations(): Boolean { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(packageName) +} + +/** 跳转到系统电池优化设置页,引导用户豁免本应用 */ +fun Context.openBatteryOptimizationSettings() { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + startActivity(intent) +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 7552b3d02..5782d0e26 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -201,6 +201,10 @@ sealed interface PreferencesKey { // Languages const val languages = "languages" + // Reading page (custom) + const val readingTitleVisibility = "readingTitleVisibility" + const val readingOpenLinkFab = "readingOpenLinkFab" + private val keyList = listOf( // Version @@ -275,6 +279,9 @@ sealed interface PreferencesKey { IntKey(sharedContent), // Languages IntKey(languages), + // Reading page (custom) + BooleanKey(readingTitleVisibility), + BooleanKey(readingOpenLinkFab), ) val keys = keyList.associateBy { it.name } @@ -363,6 +370,10 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { // Languages const val languages = "languages" + // Reading page (custom) + const val readingTitleVisibility = "readingTitleVisibility" + const val readingOpenLinkFab = "readingOpenLinkFab" + val keys: MutableMap> = mutableMapOf( // Version @@ -511,6 +522,18 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { String::class.java, ), sharedContent to DataStoreKey(intPreferencesKey(sharedContent), Int::class.java), + + // Reading page (custom) + readingTitleVisibility to + DataStoreKey( + booleanPreferencesKey(readingTitleVisibility), + Boolean::class.java, + ), + readingOpenLinkFab to + DataStoreKey( + booleanPreferencesKey(readingOpenLinkFab), + Boolean::class.java, + ), // Languages languages to DataStoreKey(intPreferencesKey(languages), Int::class.java), ) diff --git a/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt index 3d9a5fa06..4158fe46d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt @@ -131,13 +131,32 @@ constructor( .map { it.any { workInfo -> workInfo.state == WorkInfo.State.RUNNING } } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val syncProgressFlow = + workManager + .getWorkInfosByTagFlow(SyncWorker.SYNC_TAG) + .map { workInfos -> + workInfos + .filter { it.state == WorkInfo.State.RUNNING } + .mapNotNull { + it.progress.getInt("syncProgress", -1).takeIf { p -> p in 0..100 } + } + .maxOrNull() + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val _isSyncingFlow = MutableStateFlow(false) val isSyncingFlow = _isSyncingFlow.asStateFlow() + private val _syncProgress = MutableStateFlow(null) + val syncProgress = _syncProgress.asStateFlow() + init { viewModelScope.launch { syncWorkerStatusFlow.debounce(500L).collect { _isSyncingFlow.value = it } } + viewModelScope.launch { + syncProgressFlow.debounce(150L).collect { _syncProgress.value = it } + } } fun updateReadStatus( @@ -211,7 +230,6 @@ constructor( } fun sync() { - diffMapHolder.commitDiffsToDb() viewModelScope.launch { _isSyncingFlow.value = true val isSyncing = syncWorkerStatusFlow.value @@ -223,6 +241,9 @@ constructor( } } applicationScope.launch(ioDispatcher) { + // 注意:此处不再提交已读 diff —— 打开文章产生的灰色已读 overlay 保持到 + // 用户离开列表页(exitToHome)才统一落库;同步 reconcile 已会跳过 + // overlay 文章(见 GR/Fever 的 overlayReadIds 过滤),刷新不会把它们剔除。 val filterState = filterStateUseCase.filterStateFlow.value val service = rssService.get() when (service) { @@ -294,6 +315,10 @@ constructor( ?: rssService.get().findArticleById(articleId)!!) if (diffMapHolder.checkIfUnread(item)) { + // 打开文章自动已读:仅更新 UI overlay(返回列表时文章显示为灰色已读)。 + // 持久化落库/推远端交给 commit 时机(下拉刷新前、应用退后台、 + // 以及离开整个文章列表页时 FlowPage onDispose)——避免"未读"过滤 + // 在返回列表的瞬间就把已读文章从列表剔除。 diffMapHolder.updateDiff(item, isUnread = false) } item.run { @@ -308,6 +333,7 @@ constructor( author = article.author, link = article.link, publishedDate = article.date, + titleDisplayMode = feed.titleDisplayMode, ) .prefetchArticleId() .renderContent(this) @@ -462,6 +488,8 @@ data class ReaderState( val link: String? = null, val publishedDate: Date = Date(0L), val content: ContentState = Loading, + /** 所属订阅源的标题显示预设:Feed.TITLE_DISPLAY_* */ + val titleDisplayMode: Int = Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, val listIndex: Int? = null, val nextArticle: PrefetchResult? = null, val previousArticle: PrefetchResult? = null, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt index 33f539193..722fb8100 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import me.ash.reader.R +import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.group.Group import me.ash.reader.ui.component.base.RYSelectionChip import me.ash.reader.ui.component.base.Subtitle @@ -35,6 +36,8 @@ fun FeedOptionView( selectedAllowNotificationPreset: Boolean = false, selectedParseFullContentPreset: Boolean = false, selectedOpenInBrowserPreset: Boolean = false, + showTitleDisplaySection: Boolean = false, + selectedTitleDisplayMode: Int = Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, isMoveToGroup: Boolean = false, showGroup: Boolean = true, showUnsubscribe: Boolean = true, @@ -43,6 +46,7 @@ fun FeedOptionView( allowNotificationPresetOnClick: () -> Unit = {}, parseFullContentPresetOnClick: () -> Unit = {}, openInBrowserPresetOnClick: () -> Unit = {}, + onTitleDisplayModeClick: (Int) -> Unit = {}, clearArticlesOnClick: () -> Unit = {}, unsubscribeOnClick: () -> Unit = {}, onGroupClick: (groupId: String) -> Unit = {}, @@ -59,11 +63,14 @@ fun FeedOptionView( selectedAllowNotificationPreset = selectedAllowNotificationPreset, selectedParseFullContentPreset = selectedParseFullContentPreset, selectedOpenInBrowserPreset = selectedOpenInBrowserPreset, + showTitleDisplaySection = showTitleDisplaySection, + selectedTitleDisplayMode = selectedTitleDisplayMode, showUnsubscribe = showUnsubscribe, notSubscribeMode = notSubscribeMode, allowNotificationPresetOnClick = allowNotificationPresetOnClick, parseFullContentPresetOnClick = parseFullContentPresetOnClick, openInBrowserPresetOnClick = openInBrowserPresetOnClick, + onTitleDisplayModeClick = onTitleDisplayModeClick, clearArticlesOnClick = clearArticlesOnClick, unsubscribeOnClick = unsubscribeOnClick, ) @@ -107,11 +114,14 @@ private fun Preset( selectedAllowNotificationPreset: Boolean = false, selectedParseFullContentPreset: Boolean = false, selectedOpenInBrowserPreset: Boolean = false, + showTitleDisplaySection: Boolean = false, + selectedTitleDisplayMode: Int = Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, showUnsubscribe: Boolean = true, notSubscribeMode: Boolean = false, allowNotificationPresetOnClick: () -> Unit = {}, parseFullContentPresetOnClick: () -> Unit = {}, openInBrowserPresetOnClick: () -> Unit = {}, + onTitleDisplayModeClick: (Int) -> Unit = {}, clearArticlesOnClick: () -> Unit = {}, unsubscribeOnClick: () -> Unit = {}, ) { @@ -153,6 +163,37 @@ private fun Preset( } } } + if (showTitleDisplaySection) { + Spacer(modifier = Modifier.height(26.dp)) + Subtitle(text = stringResource(R.string.title_display)) + Spacer(modifier = Modifier.height(10.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.Start), + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + ) { + RYSelectionChip( + modifier = Modifier, + content = stringResource(R.string.title_display_follow_default), + selected = selectedTitleDisplayMode == Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, + ) { + onTitleDisplayModeClick(Feed.TITLE_DISPLAY_FOLLOW_DEFAULT) + } + RYSelectionChip( + modifier = Modifier, + content = stringResource(R.string.title_display_show), + selected = selectedTitleDisplayMode == Feed.TITLE_DISPLAY_SHOW, + ) { + onTitleDisplayModeClick(Feed.TITLE_DISPLAY_SHOW) + } + RYSelectionChip( + modifier = Modifier, + content = stringResource(R.string.title_display_hide), + selected = selectedTitleDisplayMode == Feed.TITLE_DISPLAY_HIDE, + ) { + onTitleDisplayModeClick(Feed.TITLE_DISPLAY_HIDE) + } + } + } Spacer(modifier = Modifier.height(26.dp)) Subtitle(text = stringResource(R.string.preset)) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 312230861..308bcef7e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -4,9 +4,12 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -20,6 +23,7 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings @@ -34,8 +38,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf @@ -85,9 +87,19 @@ import me.ash.reader.ui.page.home.feeds.drawer.feed.FeedOptionDrawer import me.ash.reader.ui.page.home.feeds.drawer.group.GroupOptionDrawer import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewModel +import me.ash.reader.ui.page.home.flow.PullToSyncIndicator +import me.ash.reader.ui.page.home.reading.PullToLoadDefaults +import me.ash.reader.ui.page.home.reading.PullToLoadDefaults.ContentOffsetMultiple +import me.ash.reader.ui.page.home.reading.PullToLoadState +import me.ash.reader.ui.page.home.reading.pullToLoad +import me.ash.reader.ui.page.home.reading.rememberPullToLoadState import me.ash.reader.ui.page.settings.accounts.AccountViewModel -@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalSharedTransitionApi::class, + ExperimentalMaterialApi::class, +) @Composable fun FeedsPage( // navController: NavHostController, @@ -134,10 +146,12 @@ fun FeedsPage( val owner = LocalLifecycleOwner.current var isSyncing by remember { mutableStateOf(false) } - val syncingState = rememberPullToRefreshState() + var syncProgress by remember { mutableStateOf(null) } val syncingScope = rememberCoroutineScope() + val settleSpec = remember { spring(dampingRatio = Spring.DampingRatioLowBouncy) } val doSync: () -> Unit = { isSyncing = true + syncProgress = null syncingScope.launch { feedsViewModel.sync() } } @@ -157,13 +171,41 @@ fun FeedsPage( } } feedsViewModel.syncWorkLiveData.observe(owner) { workInfoList -> - workInfoList.let { - isSyncing = it.any { workInfo -> workInfo.state == WorkInfo.State.RUNNING } - } + val running = + workInfoList.firstOrNull { workInfo -> workInfo.state == WorkInfo.State.RUNNING } + isSyncing = running != null + syncProgress = + running + ?.progress + ?.getInt("syncProgress", -1) + ?.takeIf { progress -> progress in 0..100 } } onDispose { feedsViewModel.syncWorkLiveData.removeObservers(owner) } } + // 下拉刷新:与文章信息流页同一套 PullToLoad 机制与显示(同步中圆圈+百分比) + var currentPullToLoadState: PullToLoadState? by remember { mutableStateOf(null) } + val onPullToSync: (() -> Unit)? = + if (isSyncing) null + else { + { + doSync() + currentPullToLoadState?.animateDistanceTo( + targetValue = 0f, + animationSpec = settleSpec, + ) + } + } + + val pullToLoadState = + rememberPullToLoadState( + key = listState, + onLoadNext = null, + onLoadPrevious = onPullToSync, + loadThreshold = PullToLoadDefaults.loadThreshold(.1f), + ) + .also { currentPullToLoadState = it } + fun expandAllGroups() { groupWithFeedList.forEach { groupWithFeed -> groupsVisible[groupWithFeed.group.id] = true } } @@ -178,7 +220,8 @@ fun FeedsPage( BackHandler(true) { context.findActivity()?.moveTaskToBack(false) } - RYScaffold( + Box(modifier = Modifier.fillMaxSize()) { + RYScaffold( topBarTonalElevation = topBarTonalElevation.value.dp, // containerTonalElevation = groupListTonalElevation.value.dp, topBar = { @@ -228,8 +271,28 @@ fun FeedsPage( ) }, content = { - PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { - LazyColumn(modifier = Modifier.fillMaxSize().drawVerticalScrollIndicator(listState), state = listState) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = + Modifier.pullToLoad( + state = pullToLoadState, + enabled = true, + contentOffsetY = { fraction -> + if (fraction > 0f) { + (fraction * ContentOffsetMultiple * 1.5f) + .dp + .roundToPx() + } else { + (fraction * ContentOffsetMultiple * 2f) + .dp + .roundToPx() + } + }, + ) + .fillMaxSize() + .drawVerticalScrollIndicator(listState), + state = listState, + ) { item { DisplayText(text = feedsUiState.account?.name ?: "", desc = "") { hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) @@ -350,6 +413,17 @@ fun FeedsPage( }, ) + // 下拉同步指示:与文章信息流页同款(下拉进度 / 同步中转圈+百分比), + // 放在 Scaffold 外层 Box,按屏幕顶部定位,与信息流页位置一致 + currentPullToLoadState?.let { + PullToSyncIndicator( + pullToLoadState = it, + isSyncing = isSyncing, + progress = if (isSyncing) syncProgress else null, + ) + } +} + SubscribeDialog(subscribeViewModel = subscribeViewModel) GroupOptionDrawer(drawerState = groupDrawerState) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt index dac929db9..f982bc12a 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch import me.ash.reader.R +import me.ash.reader.domain.model.feed.Feed import me.ash.reader.infrastructure.preference.LocalOpenLink import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser import me.ash.reader.ui.component.ChangeUrlDialog @@ -99,6 +100,9 @@ fun FeedOptionDrawer( ?: false, selectedParseFullContentPreset = feedOptionUiState.feed?.isFullContent ?: false, selectedOpenInBrowserPreset = feedOptionUiState.feed?.isBrowser ?: false, + showTitleDisplaySection = true, + selectedTitleDisplayMode = + feedOptionUiState.feed?.titleDisplayMode ?: Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, isMoveToGroup = true, showGroup = feedOptionViewModel.rssService.get().moveSubscription, showUnsubscribe = feedOptionViewModel.rssService.get().deleteSubscription, @@ -113,6 +117,9 @@ fun FeedOptionDrawer( openInBrowserPresetOnClick = { feedOptionViewModel.changeOpenInBrowserPreset() }, + onTitleDisplayModeClick = { + feedOptionViewModel.setTitleDisplayMode(it) + }, clearArticlesOnClick = { feedOptionViewModel.showClearDialog() }, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt index 370f71e42..b68c4bd42 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt @@ -128,6 +128,15 @@ constructor( } } + fun setTitleDisplayMode(titleDisplayMode: Int) { + viewModelScope.launch(ioDispatcher) { + _feedOptionUiState.value.feed?.let { + rssService.get().updateFeed(it.copy(titleDisplayMode = titleDisplayMode)) + fetchFeed(it.id) + } + } + } + fun delete(callback: () -> Unit = {}) { _feedOptionUiState.value.feed?.let { applicationScope.launch(ioDispatcher) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupConfigurationDialogs.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupConfigurationDialogs.kt index cbdbfdc6e..94e85fb3c 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupConfigurationDialogs.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupConfigurationDialogs.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.outlined.Title import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon @@ -32,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import me.ash.reader.R +import me.ash.reader.domain.model.feed.Feed import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.interaction.alphaIndicationSelectable @@ -237,6 +239,73 @@ fun AllParseFullContentDialog( } } +@Composable +fun AllTitleDisplayDialog( + groupName: String, + groupOptionViewModel: GroupOptionViewModel = hiltViewModel(), + onConfirm: () -> Unit, +) { + val groupOptionUiState = groupOptionViewModel.groupOptionUiState.collectAsStateValue() + + if (groupOptionUiState.allTitleDisplayDialogVisible) { + var mode by remember { + mutableStateOf(Feed.TITLE_DISPLAY_FOLLOW_DEFAULT) + } + + AlertDialog( + onDismissRequest = { groupOptionViewModel.hideAllTitleDisplayDialog() }, + confirmButton = { + ApplyButton( + onClick = { + groupOptionViewModel.allTitleDisplay(mode) { + groupOptionViewModel.hideAllTitleDisplayDialog() + onConfirm() + } + } + ) + }, + dismissButton = { + CancelButton(onClick = { groupOptionViewModel.hideAllTitleDisplayDialog() }) + }, + title = { Text(stringResource(R.string.title_display)) }, + icon = { Icon(imageVector = Icons.Outlined.Title, contentDescription = null) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + stringResource( + R.string.group_configuration_description, + stringResource(R.string.title_display), + groupName, + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + SingleChoiceItem( + title = stringResource(R.string.title_display_follow_default), + description = stringResource(R.string.title_display_follow_default_desc), + selected = mode == Feed.TITLE_DISPLAY_FOLLOW_DEFAULT, + ) { + mode = Feed.TITLE_DISPLAY_FOLLOW_DEFAULT + } + SingleChoiceItem( + title = stringResource(R.string.title_display_show), + description = stringResource(R.string.title_display_show_desc), + selected = mode == Feed.TITLE_DISPLAY_SHOW, + ) { + mode = Feed.TITLE_DISPLAY_SHOW + } + SingleChoiceItem( + title = stringResource(R.string.title_display_hide), + description = stringResource(R.string.title_display_hide_desc), + selected = mode == Feed.TITLE_DISPLAY_HIDE, + ) { + mode = Feed.TITLE_DISPLAY_HIDE + } + } + }, + ) + } +} + @Composable private fun ApplyButton(onClick: () -> Unit, modifier: Modifier = Modifier) { Button(onClick = onClick, modifier = modifier) { Text(stringResource(R.string.apply)) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt index fdadde9b1..4cf84b408 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.outlined.Title import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -144,6 +145,9 @@ fun GroupOptionDrawer( AllOpenInBrowserDialog( groupName = group?.name ?: "", onConfirm = { scope.launch { drawerState.hide() } }) + AllTitleDisplayDialog( + groupName = group?.name ?: "", + onConfirm = { scope.launch { drawerState.hide() } }) AllMoveToGroupDialog( groupName = group?.name ?: "", onConfirm = { scope.launch { drawerState.hide() } }) @@ -223,6 +227,22 @@ private fun Preset( ) { viewModel.showAllOpenInBrowserDialog() } + RYSelectionChip( + modifier = Modifier, + content = stringResource(R.string.title_display), + selected = false, + selectedIcon = { + Icon( + imageVector = Icons.Outlined.Title, + contentDescription = stringResource(R.string.title_display), + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + ) + }, + ) { + viewModel.showAllTitleDisplayDialog() + } RYSelectionChip( modifier = Modifier, content = stringResource(R.string.clear_articles), diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt index a147a50c0..e75710a93 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt @@ -112,6 +112,25 @@ class GroupOptionViewModel @Inject constructor( _groupOptionUiState.update { it.copy(allOpenInBrowserDialogVisible = false) } } + fun allTitleDisplay(titleDisplayMode: Int, callback: () -> Unit = {}) { + _groupOptionUiState.value.group?.let { + viewModelScope.launch(ioDispatcher) { + rssService.get().groupTitleDisplay(it, titleDisplayMode) + withContext(mainDispatcher) { + callback() + } + } + } + } + + fun showAllTitleDisplayDialog() { + _groupOptionUiState.update { it.copy(allTitleDisplayDialogVisible = true) } + } + + fun hideAllTitleDisplayDialog() { + _groupOptionUiState.update { it.copy(allTitleDisplayDialogVisible = false) } + } + fun delete(callback: () -> Unit = {}) { _groupOptionUiState.value.group?.let { applicationScope.launch(ioDispatcher) { @@ -221,6 +240,7 @@ data class GroupOptionUiState( val allAllowNotificationDialogVisible: Boolean = false, val allParseFullContentDialogVisible: Boolean = false, val allOpenInBrowserDialogVisible: Boolean = false, + val allTitleDisplayDialogVisible: Boolean = false, val allMoveToGroupDialogVisible: Boolean = false, val deleteDialogVisible: Boolean = false, val clearDialogVisible: Boolean = false, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt index 602def14a..d247f65b0 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt @@ -305,6 +305,19 @@ fun FlowPage( } val isSyncing = viewModel.isSyncingFlow.collectAsStateValue() + val syncProgress = viewModel.syncProgress.collectAsStateValue() + + // 在列表页退出(返回首页,系统返回键或顶栏箭头)时,提交打开文章产生的已读 diff + // (落库 + 待推送)。注意:不能用 onDispose —— 点进阅读页时本页面即被导航替换出组合, + // onDispose 会在"打开文章瞬间"提交,导致返回列表时已读文章被未读过滤直接剔除。 + // 这里统一走 exitToHome:阅读→返回列表保持灰色已读;返回首页后再进入列表已读消失。 + val exitToHome: () -> Unit = { + viewModel.diffMapHolder.commitDiffsToDb() + onNavigateUp() + } + BackHandler(enabled = !onSearch && !markAsRead) { + exitToHome() + } Box(modifier = Modifier.fillMaxSize()) { RYScaffold( @@ -373,7 +386,7 @@ fun FlowPage( tint = MaterialTheme.colorScheme.onSurface, ) { onSearch = false - onNavigateUp() + exitToHome() } }, actions = { @@ -735,7 +748,11 @@ fun FlowPage( }, ) currentPullToLoadState?.let { - PullToSyncIndicator(pullToLoadState = it, isSyncing = isSyncing) + PullToSyncIndicator( + pullToLoadState = it, + isSyncing = isSyncing, + progress = if (isSyncing) syncProgress else null, + ) PullToLoadIndicator( state = it, loadAction = currentLoadAction, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/PullToSyncIndicator.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/PullToSyncIndicator.kt index 9133a1152..9cb74dbcf 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/PullToSyncIndicator.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/PullToSyncIndicator.kt @@ -5,20 +5,25 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -30,6 +35,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtMost +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -42,6 +48,7 @@ fun BoxScope.PullToSyncIndicator( pullToLoadState: PullToLoadState, modifier: Modifier = Modifier, isSyncing: Boolean, + progress: Int? = null, ) { val hapticFeedback = LocalHapticFeedback.current @@ -56,6 +63,30 @@ fun BoxScope.PullToSyncIndicator( var showIndeterminateIndicator by remember { mutableStateOf(isSyncing) } + // 显示用百分比:向真实进度快速平滑(约 200ms 内跟上),不做假进度—— + // 真实进度停滞时显示值就停在真实位置,100% 一定落在同步真实完成的时刻 + var displayedPercent by remember { mutableStateOf(progress ?: 0) } + val currentProgress by rememberUpdatedState(progress) + LaunchedEffect(isSyncing) { + if (!isSyncing) return@LaunchedEffect + val target = currentProgress + if (target != null && target in 0..100) { + if (displayedPercent > target) displayedPercent = target + } else { + displayedPercent = 0 + } + while (true) { + val real = currentProgress + if (real != null && real in 0..100 && displayedPercent < real) { + val step = ((real - displayedPercent) / 2).coerceAtLeast(1) + displayedPercent = (displayedPercent + step).coerceAtMost(real) + delay(60) + } else { + delay(150) + } + } + } + val isSyncingFlow = snapshotFlow { isSyncing } val offsetSpec = remember { @@ -140,25 +171,52 @@ fun BoxScope.PullToSyncIndicator( this.alpha = animateAlpha.value this.scaleX = animateScale.value this.scaleY = animateScale.value - } - .size(48.dp), + }, color = MaterialTheme.colorScheme.primaryFixedDim, shape = MaterialTheme.shapes.extraLarge) { - Box( - modifier = Modifier, contentAlignment = Alignment.Center - ) { - if (showIndeterminateIndicator) { - val scale = remember { Animatable(1f) } - LaunchedEffect(Unit) { - scale.animateTo(1.2f, animationSpec = scaleSpec) + if (showIndeterminateIndicator) { + val scale = remember { Animatable(1f) } + LaunchedEffect(Unit) { + scale.animateTo(1.2f, animationSpec = scaleSpec) + } + val percent = progress + if (percent != null && percent in 0..100) { + // 同步中显示转圈 + 百分比 + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LoadingIndicator( + color = MaterialTheme.colorScheme.onPrimaryFixedVariant, + modifier = Modifier + .size(24.dp) + .scale(scale.value) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$displayedPercent%", + color = MaterialTheme.colorScheme.onPrimaryFixedVariant, + style = MaterialTheme.typography.labelMedium, + ) } - LoadingIndicator( - color = MaterialTheme.colorScheme.onPrimaryFixedVariant, - modifier = Modifier - .size(38.dp) - .scale(scale.value) - ) } else { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator( + color = MaterialTheme.colorScheme.onPrimaryFixedVariant, + modifier = Modifier + .size(38.dp) + .scale(scale.value) + ) + } + } + } else { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { LoadingIndicator( progress = { fraction }, color = MaterialTheme.colorScheme.onPrimaryFixedVariant, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt index 57539845b..2e932e0ea 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Content.kt @@ -48,6 +48,7 @@ fun Content( isLoading: Boolean, contentPadding: PaddingValues = PaddingValues(), onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null, + showTitle: Boolean = true, ) { val context = LocalContext.current val subheadUpperCase = LocalReadingSubheadUpperCase.current @@ -66,6 +67,7 @@ fun Content( title = title, author = author, publishedDate = publishedDate, + showTitle = showTitle, modifier = Modifier.roundClick { link?.let { uriHandler.openUri(it) } }, ) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/Metadata.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/Metadata.kt index 90ac739af..c1f6fe53d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/Metadata.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/Metadata.kt @@ -33,6 +33,7 @@ fun Metadata( publishedDate: Date, modifier: Modifier = Modifier, author: String? = null, + showTitle: Boolean = true, ) { val context = LocalContext.current val titleBold = LocalReadingTitleBold.current @@ -60,20 +61,22 @@ fun Metadata( textAlign = titleAlign, ) Spacer(modifier = Modifier.height(4.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - text = if (titleUpperCase.value) titleUpperCaseString else title, - color = MaterialTheme.colorScheme.onSurface, - style = - MaterialTheme.typography.headlineLarge - .merge( - fontFamily = fontFamily, - fontWeight = if (titleBold.value) FontWeight.Bold else FontWeight.Medium, - ) - .applyTextDirection(requiresBidi = title.requiresBidi()), - textAlign = titleAlign, - ) - Spacer(modifier = Modifier.height(4.dp)) + if (showTitle) { + Text( + modifier = Modifier.fillMaxWidth(), + text = if (titleUpperCase.value) titleUpperCaseString else title, + color = MaterialTheme.colorScheme.onSurface, + style = + MaterialTheme.typography.headlineLarge + .merge( + fontFamily = fontFamily, + fontWeight = if (titleBold.value) FontWeight.Bold else FontWeight.Medium, + ) + .applyTextDirection(requiresBidi = title.requiresBidi()), + textAlign = titleAlign, + ) + Spacer(modifier = Modifier.height(4.dp)) + } author?.let { if (it.isNotEmpty()) { Text( diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 71b89abd9..a2ee82c11 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -15,12 +15,17 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.FloatingActionButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -35,18 +40,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import kotlin.math.abs import kotlinx.coroutines.launch import me.ash.reader.R +import me.ash.reader.domain.model.feed.Feed import me.ash.reader.infrastructure.android.TextToSpeechManager import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar import me.ash.reader.infrastructure.preference.LocalReadingBoldCharacters +import me.ash.reader.infrastructure.preference.LocalReadingOpenLinkFab import me.ash.reader.infrastructure.preference.LocalReadingTextLineHeight +import me.ash.reader.infrastructure.preference.LocalReadingTitleVisibility import me.ash.reader.infrastructure.preference.not import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.showToast @@ -70,12 +81,22 @@ fun ReadingPage( ) { val context = LocalContext.current val hapticFeedback = LocalHapticFeedback.current + val uriHandler = LocalUriHandler.current val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val readingUiState = viewModel.readingUiState.collectAsStateValue() val readerState = viewModel.readerStateStateFlow.collectAsStateValue() val boldCharacters = LocalReadingBoldCharacters.current val coroutineScope = rememberCoroutineScope() + // 阅读页是否显示文章大标题:源/组预设 > 全局默认 + val showTitle = + when (readerState.titleDisplayMode) { + Feed.TITLE_DISPLAY_SHOW -> true + Feed.TITLE_DISPLAY_HIDE -> false + else -> LocalReadingTitleVisibility.current.value + } + val isOpenLinkFabEnabled = LocalReadingOpenLinkFab.current + var isReaderScrollingDown by remember { mutableStateOf(false) } var showFullScreenImageViewer by remember { mutableStateOf(false) } @@ -262,6 +283,7 @@ fun ReadingPage( isLoading = content is ReaderState.Loading, scrollState = scrollState, listState = listState, + showTitle = showTitle, onImageClick = { imgUrl, altText -> currentImageData = ImageData(imgUrl, altText) showFullScreenImageViewer = true @@ -277,6 +299,24 @@ fun ReadingPage( } } } + // Open original link FAB (bottom right, one-hand friendly) + if (isOpenLinkFabEnabled.value && readerState.link != null) { + FloatingActionButton( + onClick = { readerState.link?.let { uriHandler.openUri(it) } }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding( + end = 16.dp, + bottom = if (isShowToolBar) 100.dp else 56.dp, + ), + ) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser), + ) + } + } // Bottom Bar if (readerState.articleId != null) { BottomBar( diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt index fbc5feb8d..07021a681 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt @@ -54,6 +54,12 @@ class AccountViewModel @Inject constructor( applicationScope.launch(ioDispatcher) { accountService.update(accountId, block) rssService.get(accountId).clearAuthorization() + // 同步设置(间隔/仅 WiFi/仅充电)变更后立即重排周期任务,无需重启应用 + if (accountId == accountService.getCurrentAccountId()) { + accountService.getAccountById(accountId)?.let { + rssService.get(it.type.id).reschedulePeriodicWork(it) + } + } } } @@ -83,6 +89,8 @@ class AccountViewModel @Inject constructor( fun delete(accountId: Int, callback: () -> Unit = {}) { viewModelScope.launch(ioDispatcher) { accountService.delete(accountId) + // 删除账户后,为目标账户重建周期同步任务 + rssService.get().reschedulePeriodicWork() withContext(mainDispatcher) { callback() } @@ -103,9 +111,10 @@ class AccountViewModel @Inject constructor( addAccountJob = applicationScope.launch(ioDispatcher) { val addAccount = accountService.addAccount(account) try { - val rssService = rssService.get(addAccount.type.id) - if (rssService.validCredentials(account)) { - rssService.doSyncOneTime() + val rssRepo = rssService.get(addAccount.type.id) + if (rssRepo.validCredentials(account)) { + rssRepo.doSyncOneTime() + rssRepo.initSync() withContext(mainDispatcher) { callback(addAccount, null) } @@ -126,6 +135,9 @@ class AccountViewModel @Inject constructor( fun switchAccount(targetAccount: Account, callback: () -> Unit = {}) { viewModelScope.launch(ioDispatcher) { accountService.switch(targetAccount) + // 切换账户后立即以新账户的设置重排周期任务, + // 避免周期任务继续携带旧账户的 accountId 运行 + rssService.get(targetAccount.type.id).reschedulePeriodicWork(targetAccount) withContext(mainDispatcher) { callback() } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt index 59c389542..d01800da3 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/color/reading/ReadingStylePage.kt @@ -43,9 +43,11 @@ import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar import me.ash.reader.infrastructure.preference.LocalReadingBoldCharacters import me.ash.reader.infrastructure.preference.LocalReadingFonts +import me.ash.reader.infrastructure.preference.LocalReadingOpenLinkFab import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation import me.ash.reader.infrastructure.preference.LocalReadingRenderer import me.ash.reader.infrastructure.preference.LocalReadingTheme +import me.ash.reader.infrastructure.preference.LocalReadingTitleVisibility import me.ash.reader.infrastructure.preference.ReadingFontsPreference import me.ash.reader.infrastructure.preference.ReadingPageTonalElevationPreference import me.ash.reader.infrastructure.preference.ReadingRendererPreference @@ -84,6 +86,8 @@ fun ReadingStylePage( val pullToSwitchArticle = LocalPullToSwitchArticle.current val renderer = LocalReadingRenderer.current val boldCharacters = LocalReadingBoldCharacters.current + val titleVisibility = LocalReadingTitleVisibility.current + val openLinkFab = LocalReadingOpenLinkFab.current var tonalElevationDialogVisible by remember { mutableStateOf(false) } var rendererDialogVisible by remember { mutableStateOf(false) } @@ -198,6 +202,28 @@ fun ReadingStylePage( (!autoHideToolbar).put(context, scope) } } + SettingItem( + title = stringResource(R.string.show_title_in_reading_page), + desc = stringResource(R.string.show_title_in_reading_page_desc), + onClick = { + (!titleVisibility).put(context, scope) + }, + ) { + RYSwitch(activated = titleVisibility.value) { + (!titleVisibility).put(context, scope) + } + } + SettingItem( + title = stringResource(R.string.open_in_browser_fab), + desc = stringResource(R.string.open_in_browser_fab_desc), + onClick = { + (!openLinkFab).put(context, scope) + }, + ) { + RYSwitch(activated = openLinkFab.value) { + (!openLinkFab).put(context, scope) + } + } SettingItem( title = stringResource(R.string.rearrange_buttons), enabled = false, diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/troubleshooting/TroubleshootingPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/troubleshooting/TroubleshootingPage.kt index f38f6b6b1..96e0fdb50 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/troubleshooting/TroubleshootingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/troubleshooting/TroubleshootingPage.kt @@ -64,6 +64,8 @@ import me.ash.reader.ui.component.base.Subtitle import me.ash.reader.ui.ext.DateFormat import me.ash.reader.ui.ext.MimeType import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.ext.isIgnoringBatteryOptimizations +import me.ash.reader.ui.ext.openBatteryOptimizationSettings import me.ash.reader.ui.ext.getCurrentVersion import me.ash.reader.ui.ext.openURL import me.ash.reader.ui.ext.toString @@ -181,6 +183,29 @@ fun TroubleshootingPage(onBack: () -> Unit, viewModel: TroubleshootingViewModel nextScheduledMillis = it.nextScheduleTimeMillis, ) } + + item { + Spacer(modifier = Modifier.height(24.dp)) + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.battery_optimization), + ) + } + item { + val isExempted = context.isIgnoringBatteryOptimizations() + SettingItem( + title = stringResource(R.string.battery_optimization), + desc = if (isExempted) + stringResource(R.string.battery_optimization_exempted) + else + stringResource(R.string.battery_optimization_restricted), + onClick = { + if (!isExempted) { + context.openBatteryOptimizationSettings() + } + }, + ) {} + } if (syncLogList.isNotEmpty()) { item { Subtitle( @@ -262,11 +287,20 @@ fun WorkInfo( nextScheduledMillis: Long, modifier: Modifier = Modifier, ) { - val date = remember(nextScheduledMillis) { Date(nextScheduledMillis) } + val date = remember(nextScheduledMillis) { + val oneYearLater = System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000 + if (nextScheduledMillis <= 0L || nextScheduledMillis >= oneYearLater * 1000) "N/A" + else { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) + sdf.format(java.util.Date(nextScheduledMillis)) + } + } Column(modifier = modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { Text(tags.toString(), style = MaterialTheme.typography.bodyLarge) Text(state.toString(), style = MaterialTheme.typography.bodySmall) - if (tags.contains(PERIODIC_WORK_TAG) && state != WorkInfo.State.FAILED) { + // 只有 ENQUEUED 的 nextScheduleTimeMillis 才有意义, + // 其他状态(CANCELLED/SUCCEEDED/FAILED/RUNNING)都是 Long.MAX_VALUE → 292278994 年 Aug 17 + if (tags.contains(PERIODIC_WORK_TAG) && state == WorkInfo.State.ENQUEUED) { Text("Next scheduled time: $date", style = MaterialTheme.typography.bodySmall) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 439703cb8..2582d5c25 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -291,6 +291,10 @@ 故障排除 错误报告、应用选项 错误报告 + 后台运行 + 允许后台运行可确保定时同步不受系统省电限制影响 + 已允许后台运行 + 系统限制了后台运行,定时同步可能失效 应用程序偏好设置 导入 protobuf 文件 导出为 protobuf 文件 @@ -314,6 +318,17 @@ 用默认浏览器打开 “%1$s”分组的全部文章 已允许用默认浏览器打开“%1$s”分组的所有文章 已拒绝用默认浏览器打开“%1$s”分组的所有文章 + 标题显示 + 跟随默认 + 跟随全局默认设置 + 显示标题 + 在阅读页显示文章标题 + 隐藏标题 + 隐藏标题,只显示正文 + 阅读页显示标题 + 文章标题的全局默认显示设置;长按订阅源列表中的源或分组,可单独覆盖 + 悬浮打开原文按钮 + 在阅读页右下角显示悬浮按钮,点击后在浏览器打开原文链接,与点击标题效果一致 客户端证书(可选) 最新 最早 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..9571c8cf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,17 @@ Open all articles in the \"%1$s\" group using the default browser Opening all articles in the \"%1$s\" group in the default browser is allowed Opening all articles in the \"%1$s\" group in the default browser is denied + Title display + Follow default + Follow the global default setting + Show title + Show the article title in the reading page + Hide title + Hide the title and show only the content + Show title in reading page + Global default for showing the article title. Long press a feed or group in the feeds page to override it per source. + Open-link floating button + A floating button at the bottom right of the reading page that opens the original link in the browser, same as tapping the title. Clear articles Cleared all archived articles in the \"%1$s\" feed Cleared all archived articles in the \"%1$s\" group @@ -332,6 +343,10 @@ Troubleshooting Bug report, app preferences Bug report + Background running + Allow background running to keep periodic sync reliable + Background running allowed + System restricts background running, sync may be unreliable https://github.com/ReadYouApp/ReadYou/issues App preferences Import from protobuf file