From 58256aa991ec6bcfbe29cd2785297fe19bdb7044 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sun, 19 Jul 2026 03:00:34 +0000 Subject: [PATCH 01/23] fix: prevent sync worker from stalling after persistent errors - SyncWorker: cap retry at 3 attempts, then fail to let periodic reset; use accountId from inputData to dispatch correct service type - ReaderWorker: cap retry to avoid blocking POST_SYNC_WORK chain indefinitely - AbstractRssRepository: extract reschedulePeriodicWork() for reuse - AccountViewModel: reschedule periodic tasks immediately after account switch or sync setting change - FeverRssService: use retry instead of failure for consistency --- .gitignore | 4 ++- .../domain/service/AbstractRssRepository.kt | 32 +++++++++-------- .../reader/domain/service/FeverRssService.kt | 3 +- .../ash/reader/domain/service/ReaderWorker.kt | 9 ++++- .../ash/reader/domain/service/SyncWorker.kt | 34 +++++++++++++++---- .../settings/accounts/AccountViewModel.kt | 9 +++++ 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 6e36aaaf4..35acad88e 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,6 @@ out/ ### VS Code ### .vscode/ -signature \ No newline at end of file +signature + +AGENTS.md \ No newline at end of file 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..3b0200ca2 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 @@ -187,22 +187,26 @@ abstract class AbstractRssRepository( fun initSync() { accountService.getCurrentAccount().let { - val syncOnStart = it.syncOnStart.value - if (syncOnStart) { + if (it.syncOnStart.value) { 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) - } + reschedulePeriodicWork(it) + } + } + + /** 按账户的同步设置重排(或取消)周期性同步任务,设置变更后应立即调用以生效 */ + 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) } } 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..1e753cbda 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 @@ -321,7 +321,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/ReaderWorker.kt b/app/src/main/java/me/ash/reader/domain/service/ReaderWorker.kt index 4c641f477..4dfb23456 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 @@ -36,6 +36,13 @@ constructor( } } - 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..9d053a9e7 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 @@ -8,6 +8,7 @@ import dagger.assisted.AssistedInject import java.util.concurrent.TimeUnit import me.ash.reader.domain.model.account.Account import me.ash.reader.infrastructure.rss.ReaderCacheHelper +import timber.log.Timber @HiltWorker class SyncWorker @@ -16,6 +17,7 @@ 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) { @@ -23,17 +25,33 @@ constructor( override suspend fun doWork(): 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() + // 按任务携带的 accountId 分发到对应类型的服务, + // 避免周期任务在切换账户后仍按“当前账户”类型分发而永久失败 + val account = accountService.getAccountById(accountId) ?: return Result.failure() + val service = rssService.get(account.type.id) + + return 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 + } .also { - rssService.get().clearKeepArchivedArticles().forEach { - readerCacheHelper.deleteCacheFor(articleId = it.id) - } + runCatching { + service.clearKeepArchivedArticles().forEach { + readerCacheHelper.deleteCacheFor(articleId = it.id) + } + } + .onFailure { Timber.e(it, "Failed to clear archived articles") } workManager .beginUniqueWork( uniqueWorkName = POST_SYNC_WORK_NAME, @@ -55,6 +73,10 @@ constructor( 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" 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..313065b2b 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) + } + } } } @@ -126,6 +132,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() } From e1effb6799903beb7c9135582dbe908c7c20f03f Mon Sep 17 00:00:00 2001 From: Left024 Date: Sun, 19 Jul 2026 11:23:48 +0000 Subject: [PATCH 02/23] fix: reset lastEnqueueTime on app restart to prevent stale schedule - SyncWorker.enqueuePeriodicWork: use CANCEL_AND_REENQUEUE for ENQUEUED state (only keep UPDATE for RUNNING), so that lastEnqueueTime is reset to current system time on every app start - WidgetUpdateWorker.enqueuePeriodicWork: same treatment - This prevents a one-time clock anomaly from permanently polluting the nextScheduleTimeMillis (symptom: always showing Aug 17 regardless of real date) --- .../me/ash/reader/domain/service/SyncWorker.kt | 5 ++++- .../reader/domain/service/WidgetUpdateWorker.kt | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) 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 9d053a9e7..24089170a 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 @@ -122,8 +122,11 @@ constructor( .firstOrNull() ?.state + // 仅进程内正在运行时用 UPDATE 保留现场, + // 其他状态全部 CANCEL_AND_REENQUEUE 以重置 lastEnqueueTime, + // 避免系统时钟曾被修改导致的永久性 future schedule 污染 val policy = - if (workState == WorkInfo.State.ENQUEUED || workState == WorkInfo.State.RUNNING) + if (workState == WorkInfo.State.RUNNING) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE 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() From a0a4cae851fddc558b5973c7a0e774a72563e37a Mon Sep 17 00:00:00 2001 From: Left024 Date: Wed, 22 Jul 2026 10:25:16 +0000 Subject: [PATCH 03/23] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8F=AF=E9=9D=A0=E6=80=A7=EF=BC=9A=E5=A4=9A=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E3=80=81=E7=94=B5=E6=B1=A0=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=81=E7=BD=91=E7=BB=9C=E6=81=A2=E5=A4=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E3=80=81=E9=80=9A=E7=9F=A5=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AbstractRssRepository: clearKeepArchivedArticles 接受 accountId 参数;initSync 遍历所有账户独立调度 - SyncWorker: doWork 顶捕异常;POST_SYNC_WORK 传递 accountId;一次性任务 setExpedited;抽取 schedule 构建逻辑 - ReaderWorker: 注入 AccountService;从 inputData 读取 accountId 兼容旧任务回退 - AndroidApp: 注册 NetworkCallback,网络恢复时触发一次性同步 - AndroidManifest: 添加 REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 权限 - NotificationHelper: InboxStyle 展示标题列表;setAutoCancel;确定性通知 ID - ContextExt: 新增电池优化状态检查与设置跳转扩展 - AccountViewModel: 删除账户后重建周期同步;修复局部变量命名歧义 - TroubleshootingPage: 新增电池优化状态卡片;修复 nextScheduledMillis 异常崩溃 - strings: 新增电池优化相关字符串(中英文) --- app/src/googlePlay/AndroidManifest.xml | 1 + app/src/main/AndroidManifest.xml | 1 + .../domain/service/AbstractRssRepository.kt | 18 ++- .../ash/reader/domain/service/ReaderWorker.kt | 13 +- .../ash/reader/domain/service/SyncWorker.kt | 136 ++++++++++-------- .../infrastructure/android/AndroidApp.kt | 23 +++ .../android/NotificationHelper.kt | 7 +- .../java/me/ash/reader/ui/ext/ContextExt.kt | 15 ++ .../settings/accounts/AccountViewModel.kt | 9 +- .../troubleshooting/TroubleshootingPage.kt | 38 ++++- app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 12 files changed, 190 insertions(+), 79 deletions(-) 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 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,11 +187,14 @@ abstract class AbstractRssRepository( } fun initSync() { - accountService.getCurrentAccount().let { - if (it.syncOnStart.value) { - doSyncOneTime(it.id!!) + val accounts = runBlocking { accountService.getAccounts().first() } + accounts.forEach { account -> + if (account.id == accountService.getCurrentAccountId()) { + if (account.syncOnStart.value) { + doSyncOneTime(account.id!!) + } } - reschedulePeriodicWork(it) + reschedulePeriodicWork(account) } } 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 4dfb23456..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,15 +22,24 @@ 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) } } } 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 24089170a..e145a6a43 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,6 +6,7 @@ import androidx.work.* import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException import me.ash.reader.domain.model.account.Account import me.ash.reader.infrastructure.rss.ReaderCacheHelper import timber.log.Timber @@ -23,6 +24,19 @@ constructor( ) : 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 suspend fun doWorkInternal(): Result { val data = inputData val accountId = data.getInt("accountId", -1) if (accountId == -1) return Result.failure() @@ -45,32 +59,38 @@ constructor( Result.failure() } else result } - .also { - runCatching { - service.clearKeepArchivedArticles().forEach { - readerCacheHelper.deleteCacheFor(articleId = it.id) - } - } - .onFailure { Timber.e(it, "Failed to clear archived articles") } - 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(), - ) - .then(OneTimeWorkRequestBuilder().build()) - .enqueue() + .let { result -> + if (result is Result.Success) { + runCatching { doPostSync(service, accountId) } + .onFailure { Timber.e(it, "Failed to run post-sync tasks") } + } + result } } + 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, + ) + .build(), + ) + .then(OneTimeWorkRequestBuilder().build()) + .enqueue() + } + companion object { private const val SYNC_WORK_NAME_PERIODIC = "ReadYou" @@ -106,56 +126,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 - - // 仅进程内正在运行时用 UPDATE 保留现场, - // 其他状态全部 CANCEL_AND_REENQUEUE 以重置 lastEnqueueTime, - // 避免系统时钟曾被修改导致的永久性 future schedule 污染 - val policy = - if (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/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/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/page/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt index 313065b2b..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 @@ -89,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() } @@ -109,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) } 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..2166d8f3b 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 文件 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..1a69f8204 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -332,6 +332,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 From b3bb5b7916348f9f66583a3854256fecea823519 Mon Sep 17 00:00:00 2001 From: Left024 Date: Thu, 3 Sep 2026 13:27:11 +0000 Subject: [PATCH 04/23] chore: ignore .dsh scratch directory --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 35acad88e..982c5659b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,4 @@ out/ signature -AGENTS.md \ No newline at end of file +AGENTS.md.dsh/ From 7a210f298592ade10a4183458936ef41eafc8793 Mon Sep 17 00:00:00 2001 From: Left024 Date: Thu, 3 Sep 2026 13:27:26 +0000 Subject: [PATCH 05/23] fix: split .gitignore AGENTS.md and .dsh rules --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 982c5659b..58891cfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ out/ signature -AGENTS.md.dsh/ +AGENTS.md +.dsh/ From 59b7d49f4ee9894aaeebf7ec1eef2f8e7ab4bb1d Mon Sep 17 00:00:00 2001 From: Left024 Date: Thu, 3 Sep 2026 13:28:47 +0000 Subject: [PATCH 06/23] chore: ignore Kotlin session dir and AS profiler artifacts --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 58891cfe0..4ce94aeac 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,9 @@ signature AGENTS.md .dsh/ + +# Android Studio (additional) +.kotlin/ +*.hprof +.externalNativeBuild/ +*.aab From db5e503cf696823b5b49046c81902b2e44320ea1 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 16:22:25 +0000 Subject: [PATCH 07/23] feat: reading title display control (global default + per feed/group preset) and open-link FAB - Feed.titleDisplayMode column (0=follow global, 1=always show, 2=always hide) + DB migration 7->8 - DAO bulk update by group; insertOrUpdate preserves local preset on remote sync - Settings: show-title global default & open-link FAB switch (ReadingStylePage), default on - Reading page: hides the big title per preset chain (feed/group > global); list unaffected - Reading page: bottom-right circular FAB opens the original link (same as tapping the title) - Feeds page long-press: per-feed tri-state chips; per-group tri-state apply dialog --- .../8.json | 421 ++++++++++++++++++ .../me/ash/reader/domain/model/feed/Feed.kt | 19 +- .../ash/reader/domain/repository/FeedDao.kt | 14 + .../domain/service/AbstractRssRepository.kt | 8 + .../infrastructure/db/AndroidDatabase.kt | 15 +- .../infrastructure/preference/Preference.kt | 2 + .../ReadingOpenLinkFabPreference.kt | 54 +++ .../ReadingTitleVisibilityPreference.kt | 56 +++ .../infrastructure/preference/Settings.kt | 2 + .../preference/SettingsProvider.kt | 2 + .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 23 + .../adaptive/ArticleListReaderViewModel.kt | 3 + .../ui/page/home/feeds/FeedOptionView.kt | 41 ++ .../feeds/drawer/feed/FeedOptionDrawer.kt | 7 + .../feeds/drawer/feed/FeedOptionViewModel.kt | 9 + .../drawer/group/GroupConfigurationDialogs.kt | 69 +++ .../feeds/drawer/group/GroupOptionDrawer.kt | 20 + .../drawer/group/GroupOptionViewModel.kt | 20 + .../reader/ui/page/home/reading/Content.kt | 2 + .../reader/ui/page/home/reading/Metadata.kt | 31 +- .../ui/page/home/reading/ReadingPage.kt | 40 ++ .../color/reading/ReadingStylePage.kt | 26 ++ app/src/main/res/values-zh-rCN/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + 24 files changed, 890 insertions(+), 16 deletions(-) create mode 100644 app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/ReadingOpenLinkFabPreference.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/ReadingTitleVisibilityPreference.kt 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/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 6c3de3748..3a8ad862e 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 @@ -402,6 +402,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/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/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/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..25656d5ec 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 @@ -308,6 +308,7 @@ constructor( author = article.author, link = article.link, publishedDate = article.date, + titleDisplayMode = feed.titleDisplayMode, ) .prefetchArticleId() .renderContent(this) @@ -462,6 +463,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/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/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/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/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2166d8f3b..2582d5c25 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -318,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 1a69f8204..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 From 75f0c87373917404d7795c45df35b7e38d18e28a Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 16:47:17 +0000 Subject: [PATCH 08/23] fix: never reset locally-read articles to unread on sync; show sync progress percentage Read-state (GoogleReader/TTRSS & Fever): - Sync no longer force-marks locally read articles back to unread when the remote snapshot still lists them unread; instead the read state is pushed to the server (idempotent, retried every sync until the server converges). - Fever: article import no longer overwrites existing rows (preserves local read/ starred); notifications only for genuinely new articles; same local-read-wins reconcile with server push. Sync progress: - AbstractRssRepository exposes onSyncProgress; Local/GR/Fever report staged percentages; SyncWorker forwards them via WorkManager setProgress. - Flow pull-to-refresh indicator shows percentage next to the spinner. --- .../domain/service/AbstractRssRepository.kt | 8 +++ .../reader/domain/service/FeverRssService.kt | 38 +++++++++-- .../domain/service/GoogleReaderRssService.kt | 67 +++++++++++-------- .../reader/domain/service/LocalRssService.kt | 8 +++ .../ash/reader/domain/service/SyncWorker.kt | 49 +++++++++----- .../adaptive/ArticleListReaderViewModel.kt | 19 ++++++ .../ash/reader/ui/page/home/flow/FlowPage.kt | 7 +- .../ui/page/home/flow/PullToSyncIndicator.kt | 62 ++++++++++++----- 8 files changed, 193 insertions(+), 65 deletions(-) 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 3a8ad862e..c21df794e 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 @@ -43,6 +43,14 @@ abstract class AbstractRssRepository( private val accountService: AccountService, ) { + /** 同步进度上报(0-100),由 SyncWorker 挂载,用于刷新进度显示。 */ + @Volatile + var onSyncProgress: ((Int) -> Unit)? = null + + protected fun reportProgress(progress: Int) { + onSyncProgress?.invoke(progress.coerceIn(0, 100)) + } + open val importSubscription: Boolean = true open val addSubscription: Boolean = true open val moveSubscription: Boolean = true 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 1e753cbda..9c8c04492 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 @@ -168,6 +168,7 @@ constructor( ) } ?: emptyList() groupDao.insertOrUpdate(groups) + reportProgress(10) // 2. Fetch the Fever feeds val feedsBody = feverAPI.getFeeds() @@ -213,11 +214,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 +254,9 @@ constructor( } allArticles.addAll(articlesFromBatch) + pageNo++ + // 翻页拉取进度(批次估算,最多按 40 页封顶) + reportProgress((30 + 55 * pageNo / 40).coerceAtMost(85)) lastSeenId = fetchedItems.lastOrNull()?.id ?: break @@ -258,13 +264,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 +286,30 @@ 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 (!meta.isUnread && shouldBeUnread == true) { + // 本地已读但远端未读:推送已读到远端,本地保持已读(避免同步把已读打回未读) + runCatching { + feverAPI.markItem( + status = FeverDTO.StatusEnum.Read, + id = articleId, + ) + } + } else { + articleDao.markAsReadByArticleId( + accountId, + meta.id, + shouldBeUnread ?: true, + ) + } } if (meta.isStarred != shouldBeStarred) { articleDao.markAsStarredByArticleId( @@ -290,6 +319,7 @@ constructor( ) } } + reportProgress(100) // Remove orphaned groups and feeds, after synchronizing the starred/un-starred val groupIds = groups.map { it.id } 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..d78814888 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 @@ -267,6 +267,7 @@ constructor( } val googleReaderAPI = getGoogleReaderAPI() googleReaderAPI.refreshCredentialsIfNeeded() + reportProgress(5) val lastMonthAt = Calendar.getInstance() .apply { @@ -362,30 +363,22 @@ 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, - ) } } - // - // 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() @@ -439,6 +432,7 @@ constructor( scope = this, ) .toMutableList() + reportProgress(30) val remoteGroups = async { groupWithFeedsMap.await().keys.toList() } val remoteFeeds = async { groupWithFeedsMap.await().values.flatten() } @@ -470,10 +464,16 @@ constructor( val articlesToNotify = mutableListOf
() if (deferredList.isNotEmpty()) { + val contentTotal = deferredList.size + var contentDone = 0 launch { whileSelect { for (deferred in deferredList) { deferred.onAwait { + contentDone++ + reportProgress( + 30 + (60 * contentDone / contentTotal).coerceAtMost(60) + ) articleDao.insertList(it) articlesToNotify.addAll( it.fastFilter { @@ -496,8 +496,12 @@ constructor( } } } + } else { + reportProgress(90) } + reportProgress(90) + // 8. Remove orphaned groups and feeds, after synchronizing the // starred/un-starred groupDao @@ -509,6 +513,7 @@ constructor( .filter { it.id !in remoteFeeds.await().map { feed -> feed.id } } .forEach { super.deleteFeed(it, true) } + reportProgress(100) accountService.update(account.copy(updateAt = Date())) ListenableWorker.Result.success() } catch (e: Exception) { @@ -589,6 +594,7 @@ constructor( } val toFetch = remoteAllIds.await() - localIds + reportProgress(30) val items = fetchItemsContents( @@ -598,6 +604,7 @@ constructor( unreadIds = remoteUnreadIds.await(), starredIds = remoteStarredIds.await(), ) + reportProgress(88) if (feed.isNotification) { val articlesToNotify = items.fastFilter { it.isUnread } @@ -621,17 +628,18 @@ 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 toBePushedReadIds = + localReadIds.intersect(remoteUnreadIds.await()).map { it.dollarLast() }.toList() + toBePushedReadIds.chunked(500).forEach { + runCatching { + googleReaderAPI.editTag( + itemIds = it, + mark = GoogleReaderAPI.Stream.Read.tag, + unmark = null, ) } + } } launch { @@ -664,6 +672,7 @@ constructor( } articleDao.insert(*items.toTypedArray()) + reportProgress(100) Timber.i("onCompletion: ${System.currentTimeMillis() - preTime}") ListenableWorker.Result.success() 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..66914a9c0 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 @@ -76,6 +77,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 +106,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/SyncWorker.kt b/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt index e145a6a43..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 @@ -7,6 +7,9 @@ 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 @@ -36,6 +39,8 @@ constructor( } } + private val syncProgressScope = CoroutineScope(Dispatchers.Default) + private suspend fun doWorkInternal(): Result { val data = inputData val accountId = data.getInt("accountId", -1) @@ -48,24 +53,36 @@ constructor( val account = accountService.getAccountById(accountId) ?: return Result.failure() val service = rssService.get(account.type.id) - return 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 + // 挂载进度回调:服务内 reportProgress -> WorkManager setProgress, + // 供下拉刷新 UI 读取并显示百分比(工作结束后务必清理) + // 注意:CoroutineWorker.setProgress 是 suspend 函数,需在协程中调用 + service.onSyncProgress = { progress -> + syncProgressScope.launch { + runCatching { setProgress(workDataOf("syncProgress" to progress)) } } - .let { result -> - if (result is Result.Success) { - runCatching { doPostSync(service, accountId) } - .onFailure { Timber.e(it, "Failed to run post-sync tasks") } + } + 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 } - result - } + .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) { 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 25656d5ec..fe052fa78 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( 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..451ce4490 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,7 @@ fun FlowPage( } val isSyncing = viewModel.isSyncingFlow.collectAsStateValue() + val syncProgress = viewModel.syncProgress.collectAsStateValue() Box(modifier = Modifier.fillMaxSize()) { RYScaffold( @@ -735,7 +736,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..950a5fd0d 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,14 +5,18 @@ 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 @@ -42,6 +46,7 @@ fun BoxScope.PullToSyncIndicator( pullToLoadState: PullToLoadState, modifier: Modifier = Modifier, isSyncing: Boolean, + progress: Int? = null, ) { val hapticFeedback = LocalHapticFeedback.current @@ -140,25 +145,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 = "$percent%", + 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, From b33b6c3380046e9393a2c1c0af30387ca8d3c21a Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 16:53:24 +0000 Subject: [PATCH 09/23] chore: ignore local apk output dir --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4ce94aeac..f95442c91 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ AGENTS.md *.hprof .externalNativeBuild/ *.aab + +apk/ From 1706c33bf5eb8b45ca82f92c8907e2d4aece9d83 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 17:07:08 +0000 Subject: [PATCH 10/23] chore: bump version to 0.16.3 (48) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 843e299e85e4fa00557e66003ef0c35c8eb8f3e9 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 17:31:21 +0000 Subject: [PATCH 11/23] fix: persist open-article auto-read immediately; refine sync progress; feeds page percent; fever null guard - Auto read on opening an article now also commits to DB and pushes to the server right away (was diff-overlay only, lost after sync-driven list refresh) - GR sync progress: id-list paging reports (unread/starred/read) for both full account and single feed sync; progress is monotonic per sync (no regression) - Feeds page (home) pull-to-refresh now shows the sync percentage under the spinner via WorkManager progress - Fever reconcile skips read-state changes when the unread list is unavailable (null) instead of flipping locally read articles back to unread --- .gitignore | 1 + .../domain/service/AbstractRssRepository.kt | 15 +++++++- .../reader/domain/service/FeverRssService.kt | 14 +++---- .../domain/service/GoogleReaderRssService.kt | 35 ++++++++++++++---- .../reader/domain/service/LocalRssService.kt | 1 + .../adaptive/ArticleListReaderViewModel.kt | 16 ++++++++ .../reader/ui/page/home/feeds/FeedsPage.kt | 37 +++++++++++++++++-- 7 files changed, 100 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index f95442c91..c286237ab 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,4 @@ AGENTS.md *.aab apk/ +.rycheck/ 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 c21df794e..dfde0bb2e 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 @@ -47,8 +47,21 @@ abstract class AbstractRssRepository( @Volatile var onSyncProgress: ((Int) -> Unit)? = null + @Volatile + private var lastSyncProgress = -1 + + /** 在每次同步开始时调用,重置进度单调基线。 */ + protected fun beginSyncProgress() { + lastSyncProgress = -1 + } + + /** 进度只增不减,避免并发阶段上报造成百分比回退。 */ protected fun reportProgress(progress: Int) { - onSyncProgress?.invoke(progress.coerceIn(0, 100)) + val clamped = progress.coerceIn(0, 100) + if (clamped >= lastSyncProgress) { + lastSyncProgress = clamped + onSyncProgress?.invoke(clamped) + } } open val importSubscription: Boolean = true 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 9c8c04492..9dab1d9d5 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 @@ -149,6 +149,7 @@ constructor( groupId: String?, ): ListenableWorker.Result = coroutineScope { try { + beginSyncProgress() val preTime = System.currentTimeMillis() val preDate = Date(preTime) val account = accountService.getAccountById(accountId)!! @@ -294,9 +295,9 @@ constructor( val articleId = meta.id.dollarLast() val shouldBeUnread = unreadArticleIds?.contains(articleId) val shouldBeStarred = starredArticleIds?.contains(articleId) - if (meta.isUnread != shouldBeUnread) { - if (!meta.isUnread && shouldBeUnread == true) { - // 本地已读但远端未读:推送已读到远端,本地保持已读(避免同步把已读打回未读) + if (shouldBeUnread != null && meta.isUnread != shouldBeUnread) { + if (!meta.isUnread) { + // 本地已读但远端未读:推读远端,本地保持已读(避免同步把已读打回未读) runCatching { feverAPI.markItem( status = FeverDTO.StatusEnum.Read, @@ -304,11 +305,8 @@ constructor( ) } } else { - articleDao.markAsReadByArticleId( - accountId, - meta.id, - shouldBeUnread ?: true, - ) + // 本地未读且远端已读:本地标为已读 + articleDao.markAsReadByArticleId(accountId, meta.id, false) } } if (meta.isStarred != shouldBeStarred) { 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 d78814888..0c1ce7168 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 @@ -225,6 +225,7 @@ constructor( feedId: String?, groupId: String?, ): ListenableWorker.Result { + beginSyncProgress() return if (feedId != null) { syncFeed(accountId, feedId) } else { @@ -278,20 +279,27 @@ constructor( .time / 1000 val remoteUnreadIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getUnreadItemIds(continuationId = it) } + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(5 + (7 * page / 20).coerceAtMost(7)) }, + ) { googleReaderAPI.getUnreadItemIds(continuationId = it) } .map { it.shortId } .toSet() } val remoteStarredIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getStarredItemIds(continuationId = it) } + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(12 + (4 * page / 20).coerceAtMost(4)) }, + ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() } val isFreshRss = account.type.id == FreshRSS.id val remoteReadIds = async { - fetchItemIdsAndContinue { + // 已读 id 列表通常最大(近一月),占较多进度空间 + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(16 + (14 * page / 20).coerceAtMost(14)) }, + ) { googleReaderAPI.getReadItemIds( since = lastMonthAt, continuationId = it, @@ -564,7 +572,9 @@ constructor( val localIds = (localReadIds + localUnreadIds).toSet() val remoteUnreadIds = async { - fetchItemIdsAndContinue { + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(22 + (4 * page / 20).coerceAtMost(4)) }, + ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), filterRead = true, @@ -576,7 +586,10 @@ constructor( } val remoteAllIds = async { - fetchItemIdsAndContinue { + // 全部 id 列表一般最大,占大头进度 + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(5 + (17 * page / 20).coerceAtMost(17)) }, + ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), filterRead = false, @@ -588,7 +601,9 @@ constructor( } val remoteStarredIds = async { - fetchItemIdsAndContinue { googleReaderAPI.getStarredItemIds(continuationId = it) } + fetchItemIdsAndContinue( + onPage = { page -> reportProgress(26 + (4 * page / 20).coerceAtMost(4)) }, + ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() } @@ -679,12 +694,18 @@ constructor( } 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 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 66914a9c0..c897a8bcd 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 @@ -62,6 +62,7 @@ constructor( groupId: String? ): ListenableWorker.Result = supervisorScope { return@supervisorScope runCatching { + beginSyncProgress() val preTime = System.currentTimeMillis() val preDate = Date(preTime) val currentAccount = accountService.getAccountById(accountId)!! 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 fe052fa78..963bd7a6d 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 @@ -314,6 +314,22 @@ constructor( if (diffMapHolder.checkIfUnread(item)) { diffMapHolder.updateDiff(item, isUnread = false) + // 打开文章即已读:除 UI overlay 外立即落库并推送远端, + // 避免刷新/后台同步期间列表重组后读状态丢失或被远端快照覆盖 + val autoReadArticleId = item.article.id + launch(ioDispatcher) { + runCatching { + rssService + .get() + .markAsRead( + groupId = null, + feedId = null, + articleId = autoReadArticleId, + before = null, + isUnread = false, + ) + } + } } item.run { _readingUiState.update { 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..1e93d12dc 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 @@ -7,6 +7,7 @@ import androidx.compose.animation.SharedTransitionScope 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 @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -31,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -134,10 +137,12 @@ fun FeedsPage( val owner = LocalLifecycleOwner.current var isSyncing by remember { mutableStateOf(false) } + var syncProgress by remember { mutableStateOf(null) } val syncingState = rememberPullToRefreshState() val syncingScope = rememberCoroutineScope() val doSync: () -> Unit = { isSyncing = true + syncProgress = null syncingScope.launch { feedsViewModel.sync() } } @@ -157,9 +162,14 @@ 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) } } @@ -228,6 +238,7 @@ fun FeedsPage( ) }, content = { + Box(modifier = Modifier.fillMaxSize()) { PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { LazyColumn(modifier = Modifier.fillMaxSize().drawVerticalScrollIndicator(listState), state = listState) { item { @@ -329,6 +340,26 @@ fun FeedsPage( } } } + // 同步百分比:首页下拉刷新时显示在转圈下方 + if (isSyncing && syncProgress != null) { + Surface( + modifier = + Modifier + .statusBarsPadding() + .padding(top = 112.dp) + .align(Alignment.TopCenter), + color = MaterialTheme.colorScheme.primaryFixedDim, + shape = MaterialTheme.shapes.extraLarge, + ) { + Text( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp), + text = "$syncProgress%", + color = MaterialTheme.colorScheme.onPrimaryFixedVariant, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } }, bottomBar = { FilterBar( From 470d2cd0bd16cdbebf9ca4f765b8d38d90c4725c Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 17:44:55 +0000 Subject: [PATCH 12/23] ui: feeds page pull-to-refresh matches flow page indicator style Replace M3 PullToRefreshBox on the feeds (home) page with the same PullToLoad-based mechanism and PullToSyncIndicator used by the article flow page: pull-down progress circle, and while syncing the pill with spinner plus percentage shown together at the top. Progress percentage stays wired via WorkManager. --- .../reader/ui/page/home/feeds/FeedsPage.kt | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) 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 1e93d12dc..fde0c2922 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,6 +4,8 @@ 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 @@ -17,11 +19,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding 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 @@ -33,12 +35,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface 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 @@ -88,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, @@ -138,8 +147,8 @@ fun FeedsPage( var isSyncing by remember { mutableStateOf(false) } var syncProgress by remember { mutableStateOf(null) } - val syncingState = rememberPullToRefreshState() val syncingScope = rememberCoroutineScope() + val settleSpec = remember { spring(dampingRatio = Spring.DampingRatioLowBouncy) } val doSync: () -> Unit = { isSyncing = true syncProgress = null @@ -174,6 +183,29 @@ fun FeedsPage( 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 } } @@ -239,8 +271,27 @@ fun FeedsPage( }, content = { Box(modifier = Modifier.fillMaxSize()) { - PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { - LazyColumn(modifier = Modifier.fillMaxSize().drawVerticalScrollIndicator(listState), state = listState) { + 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) @@ -339,27 +390,15 @@ fun FeedsPage( ) } } - } - // 同步百分比:首页下拉刷新时显示在转圈下方 - if (isSyncing && syncProgress != null) { - Surface( - modifier = - Modifier - .statusBarsPadding() - .padding(top = 112.dp) - .align(Alignment.TopCenter), - color = MaterialTheme.colorScheme.primaryFixedDim, - shape = MaterialTheme.shapes.extraLarge, - ) { - Text( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp), - text = "$syncProgress%", - color = MaterialTheme.colorScheme.onPrimaryFixedVariant, - style = MaterialTheme.typography.labelMedium, + // 下拉同步指示:与文章信息流页同款(下拉进度 / 同步中转圈+百分比) + currentPullToLoadState?.let { + PullToSyncIndicator( + pullToLoadState = it, + isSyncing = isSyncing, + progress = if (isSyncing) syncProgress else null, ) } } - } }, bottomBar = { FilterBar( From 59def5e6f7b13ea83f1ede29ed2f76310ada0da3 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 17:56:43 +0000 Subject: [PATCH 13/23] fix: restore gray read state on article list return, defer auto-read commit Revert the immediate DB commit + server push when opening an article (it made the unread filter drop read articles from the list the moment you went back). Auto-read again only sets the UI diff overlay, so returning from the reader shows the article grayed out in the list; the commit to DB (and later server push via sync reconcile) is deferred to when the whole flow list page is left (FlowPage onDispose), which also keeps the previous sync fix intact: sync no longer flips locally read articles back to unread. --- .../adaptive/ArticleListReaderViewModel.kt | 20 ++++--------------- .../ash/reader/ui/page/home/flow/FlowPage.kt | 8 ++++++++ 2 files changed, 12 insertions(+), 16 deletions(-) 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 963bd7a6d..cef6e6459 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 @@ -313,23 +313,11 @@ constructor( ?: rssService.get().findArticleById(articleId)!!) if (diffMapHolder.checkIfUnread(item)) { + // 打开文章自动已读:仅更新 UI overlay(返回列表时文章显示为灰色已读)。 + // 持久化落库/推远端交给 commit 时机(下拉刷新前、应用退后台、 + // 以及离开整个文章列表页时 FlowPage onDispose)——避免"未读"过滤 + // 在返回列表的瞬间就把已读文章从列表剔除。 diffMapHolder.updateDiff(item, isUnread = false) - // 打开文章即已读:除 UI overlay 外立即落库并推送远端, - // 避免刷新/后台同步期间列表重组后读状态丢失或被远端快照覆盖 - val autoReadArticleId = item.article.id - launch(ioDispatcher) { - runCatching { - rssService - .get() - .markAsRead( - groupId = null, - feedId = null, - articleId = autoReadArticleId, - before = null, - isUnread = false, - ) - } - } } item.run { _readingUiState.update { 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 451ce4490..e0d4a8525 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -307,6 +308,13 @@ fun FlowPage( val isSyncing = viewModel.isSyncingFlow.collectAsStateValue() val syncProgress = viewModel.syncProgress.collectAsStateValue() + // 离开整个文章列表页(返回首页)时提交打开文章产生的已读 diff(落库 + 待推送), + // 使下次进入列表时"未读"过滤能正常剔除已读文章; + // 不能在此前提交,否则从阅读页返回的瞬间文章就会从列表消失而非显示灰色 + DisposableEffect(Unit) { + onDispose { viewModel.diffMapHolder.commitDiffsToDb() } + } + Box(modifier = Modifier.fillMaxSize()) { RYScaffold( containerTonalElevation = articleListTonalElevation.value.dp, From 3f89254c6490bfb8879cbd24c9b40221425e4800 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 18:07:03 +0000 Subject: [PATCH 14/23] fix: commit auto-read diffs only when leaving the flow list page The previous onDispose hook could fire while opening an article in some navigation layouts, committing read diffs too early (article then disappears from the unread filter right after going back). Route both exits from the article list to home through one commit point: the top-bar back arrow and the system back gesture (BackHandler) both call exitToHome, which commits the pending read diffs (DB + server push via next sync) before navigating up. Reading -> back to list still keeps the gray read overlay. --- .../ash/reader/ui/page/home/flow/FlowPage.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 e0d4a8525..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 @@ -45,7 +45,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -308,11 +307,16 @@ fun FlowPage( val isSyncing = viewModel.isSyncingFlow.collectAsStateValue() val syncProgress = viewModel.syncProgress.collectAsStateValue() - // 离开整个文章列表页(返回首页)时提交打开文章产生的已读 diff(落库 + 待推送), - // 使下次进入列表时"未读"过滤能正常剔除已读文章; - // 不能在此前提交,否则从阅读页返回的瞬间文章就会从列表消失而非显示灰色 - DisposableEffect(Unit) { - onDispose { viewModel.diffMapHolder.commitDiffsToDb() } + // 在列表页退出(返回首页,系统返回键或顶栏箭头)时,提交打开文章产生的已读 diff + // (落库 + 待推送)。注意:不能用 onDispose —— 点进阅读页时本页面即被导航替换出组合, + // onDispose 会在"打开文章瞬间"提交,导致返回列表时已读文章被未读过滤直接剔除。 + // 这里统一走 exitToHome:阅读→返回列表保持灰色已读;返回首页后再进入列表已读消失。 + val exitToHome: () -> Unit = { + viewModel.diffMapHolder.commitDiffsToDb() + onNavigateUp() + } + BackHandler(enabled = !onSearch && !markAsRead) { + exitToHome() } Box(modifier = Modifier.fillMaxSize()) { @@ -382,7 +386,7 @@ fun FlowPage( tint = MaterialTheme.colorScheme.onSurface, ) { onSearch = false - onNavigateUp() + exitToHome() } }, actions = { From e96d327f28027f8df835344fa7066b4c5f2720dc Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 18:13:32 +0000 Subject: [PATCH 15/23] fix: commit read diffs to DB before starting sync worker commitDiffsToDb was fire-and-forget and cleared the UI overlay before the DB write finished; the sync worker could then snapshot articles as still unread, so after a refresh articles read before it looked unread again. - DiffMapHolder: new suspend commitDiffsNow() that writes DB first and only then clears the overlay/cache; commitDiffsToDb delegates to it - ArticleListReaderViewModel.sync(): awaits commitDiffsNow() on the IO dispatcher before enqueueing the sync worker, so the sync snapshot (and the local-read->remote push reconcile) sees the latest read state --- .../ash/reader/domain/data/DiffMapHolder.kt | 20 ++++++++++++++++--- .../adaptive/ArticleListReaderViewModel.kt | 5 ++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt index dc32f4abc..9ea4f4f1a 100644 --- a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt +++ b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt @@ -199,12 +199,26 @@ 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() + 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() + if (markAsReadArticles.isNotEmpty()) { rssService.get().batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) + } + if (markAsUnreadArticles.isNotEmpty()) { rssService.get().batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) } + // 落库完成后再清 overlay/缓存 + clearDiffs() } private fun writeDiffsToCache() { 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 cef6e6459..562b49624 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 @@ -230,7 +230,6 @@ constructor( } fun sync() { - diffMapHolder.commitDiffsToDb() viewModelScope.launch { _isSyncingFlow.value = true val isSyncing = syncWorkerStatusFlow.value @@ -242,6 +241,10 @@ constructor( } } applicationScope.launch(ioDispatcher) { + // 先等待打开文章产生的已读 diff 落库完成,再启动同步 worker: + // 保证同步快照能看到最新本地已读(reconcile 据此向远端推读), + // 避免竞态导致刷新完成后已读文章被显示回未读 + diffMapHolder.commitDiffsNow() val filterState = filterStateUseCase.filterStateFlow.value val service = rssService.get() when (service) { From f5d76b6b222f6eb069e0ceb20baa228ca28c445a Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 18:26:54 +0000 Subject: [PATCH 16/23] fix: keep gray overlay read state stable across refresh; reconcile skips overlay reads When a refresh runs while the user is reading, the sync reconcile used to mark those articles read in the DB (remote already read because the overlay diff was pushed), which made the unread filter drop them right after the refresh. - DiffMapHolder is now a @Singleton shared session state and exposes overlayReadIds() (articles currently shown gray via the UI overlay) - GoogleReader full-account and single-feed reconcile, and Fever reconcile, skip marking DB-read any article that is still in the gray overlay, so the gray state survives the refresh - sync() no longer commits diffs up front; commit to DB stays at leave-list (exitToHome), ON_PAUSE, and cache-restore points as before --- .../me/ash/reader/domain/data/DiffMapHolder.kt | 11 +++++++++++ .../reader/domain/service/AbstractRssRepository.kt | 2 ++ .../ash/reader/domain/service/FeverRssService.kt | 11 +++++++++-- .../domain/service/GoogleReaderRssService.kt | 14 ++++++++++++-- .../ash/reader/domain/service/LocalRssService.kt | 3 +++ .../ui/page/adaptive/ArticleListReaderViewModel.kt | 7 +++---- 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt index 9ea4f4f1a..975b674be 100644 --- a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt +++ b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt @@ -29,9 +29,16 @@ import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import java.io.File import javax.inject.Inject +import javax.inject.Singleton private const val TAG = "DiffMapHolder" +/** + * 已读/未读 UI diff 的共享会话状态(单例): + * 列表页 overlay 与同步服务层需要看到同一份"灰色已读"集合, + * 以便同步 reconcile 不把仍处于 overlay 已读状态的文章提前落库/剔除。 + */ +@Singleton @OptIn(FlowPreview::class) class DiffMapHolder @Inject constructor( @ApplicationContext private val context: Context, @@ -42,6 +49,10 @@ class DiffMapHolder @Inject constructor( ) { val diffMap = mutableStateMapOf() + /** 当前处于 UI overlay"已读"(灰色)状态的文章 id(完整 db id)。 */ + fun overlayReadIds(): Set = + diffMap.filterValues { !it.isUnread }.keys.toSet() + private val pendingSyncDiffs = mutableStateMapOf() private val syncedDiffs = mutableMapOf() 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 dfde0bb2e..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 @@ -14,6 +14,7 @@ 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 @@ -41,6 +42,7 @@ abstract class AbstractRssRepository( private val dispatcherIO: CoroutineDispatcher, private val dispatcherDefault: CoroutineDispatcher, private val accountService: AccountService, + protected val diffMapHolder: DiffMapHolder, ) { /** 同步进度上报(0-100),由 SyncWorker 挂载,用于刷新进度显示。 */ 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 9dab1d9d5..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 @@ -305,8 +308,12 @@ constructor( ) } } else { - // 本地未读且远端已读:本地标为已读 - articleDao.markAsReadByArticleId(accountId, meta.id, false) + // 本地未读且远端已读:本地标为已读; + // 但跳过仍处于 UI"灰色已读 overlay"的文章(打开后未落库), + // 保持灰色直到用户离开列表页统一落库 + if (meta.id !in diffMapHolder.overlayReadIds()) { + articleDao.markAsReadByArticleId(accountId, meta.id, false) + } } } if (meta.isStarred != shouldBeStarred) { 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 0c1ce7168..0afbbe145 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 @@ -27,6 +27,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 +78,7 @@ constructor( private val workManager: WorkManager, private val accountService: AccountService, private val syncLogger: SyncLogger, + diffMapHolder: DiffMapHolder, ) : AbstractRssRepository( articleDao, @@ -88,6 +90,7 @@ constructor( ioDispatcher, defaultDispatcher, accountService, + diffMapHolder, ) { override val importSubscription: Boolean = false @@ -357,8 +360,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 { @@ -628,7 +635,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) } 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 c897a8bcd..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 @@ -15,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 @@ -43,6 +44,7 @@ constructor( private val workManager: WorkManager, private val accountService: AccountService, private val syncLogger: SyncLogger, + diffMapHolder: DiffMapHolder, ) : AbstractRssRepository( articleDao, @@ -54,6 +56,7 @@ constructor( ioDispatcher, defaultDispatcher, accountService, + diffMapHolder, ) { override suspend fun sync( 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 562b49624..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 @@ -241,10 +241,9 @@ constructor( } } applicationScope.launch(ioDispatcher) { - // 先等待打开文章产生的已读 diff 落库完成,再启动同步 worker: - // 保证同步快照能看到最新本地已读(reconcile 据此向远端推读), - // 避免竞态导致刷新完成后已读文章被显示回未读 - diffMapHolder.commitDiffsNow() + // 注意:此处不再提交已读 diff —— 打开文章产生的灰色已读 overlay 保持到 + // 用户离开列表页(exitToHome)才统一落库;同步 reconcile 已会跳过 + // overlay 文章(见 GR/Fever 的 overlayReadIds 过滤),刷新不会把它们剔除。 val filterState = filterStateUseCase.filterStateFlow.value val service = rssService.get() when (service) { From cea6890a509739d30d3a0b0c3d763a0d503fc9d3 Mon Sep 17 00:00:00 2001 From: Left024 Date: Fri, 4 Sep 2026 18:35:13 +0000 Subject: [PATCH 17/23] fix: break Hilt dependency cycle DiffMapHolder <-> RssService DiffMapHolder now receives Provider (resolved lazily via currentRssRepository()) instead of the eager RssService instance, breaking the cycle RssService -> LocalRss/Fever/GoogleReaderRssService -> DiffMapHolder -> RssService at the graph level. --- .../me/ash/reader/domain/data/DiffMapHolder.kt | 15 +++++++++++---- .../reader/infrastructure/di/CacheHolderModule.kt | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt index 975b674be..8e248382d 100644 --- a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt +++ b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt @@ -23,12 +23,14 @@ import kotlinx.coroutines.withContext import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.account.AccountType import me.ash.reader.domain.model.article.ArticleWithFeed +import me.ash.reader.domain.service.AbstractRssRepository import me.ash.reader.domain.service.AccountService import me.ash.reader.domain.service.RssService import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import java.io.File import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton private const val TAG = "DiffMapHolder" @@ -45,7 +47,7 @@ class DiffMapHolder @Inject constructor( @ApplicationScope private val applicationScope: CoroutineScope, @IODispatcher private val ioDispatcher: CoroutineDispatcher, private val accountService: AccountService, - private val rssService: RssService, + private val rssServiceProvider: Provider, ) { val diffMap = mutableStateMapOf() @@ -53,6 +55,10 @@ class DiffMapHolder @Inject constructor( 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() @@ -222,11 +228,12 @@ class DiffMapHolder @Inject constructor( 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()) { - rssService.get().batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) + repository.batchMarkAsRead(articleIds = markAsReadArticles, isUnread = false) } if (markAsUnreadArticles.isNotEmpty()) { - rssService.get().batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) + repository.batchMarkAsRead(articleIds = markAsUnreadArticles, isUnread = true) } // 落库完成后再清 overlay/缓存 clearDiffs() @@ -256,7 +263,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/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 ) } From ca7bfad7b13b63cf3e629a8de7227f84b05eb4d5 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 03:44:18 +0000 Subject: [PATCH 18/23] fix: do not clear read overlay when account row updates after sync GR/Fever/Local sync end with accountService.update(account.copy(updateAt=Date())), which re-emits currentAccountFlow. DiffMapHolder treated that as an account change and ran cleanup(), clearing the gray read overlay and making articles appear unread right after a refresh finished. Only cleanup on real account id change now. Also snapshot diffMap before async cache write to avoid losing diffs during cleanup. --- .../me/ash/reader/domain/data/DiffMapHolder.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt index 8e248382d..cb901f127 100644 --- a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt +++ b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt @@ -88,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) + } } } } @@ -240,9 +246,11 @@ class DiffMapHolder @Inject constructor( } 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()) { From ebde6ba247079d9a415423962a25cbd7b4af387b Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 04:37:59 +0000 Subject: [PATCH 19/23] fix: smooth sync progress percentage during refresh - fetchItemIdsAndContinue pages start at 1 and the old progress formulas used integer division (7*page/20 etc.), so intermediate percentages were swallowed and the UI jumped 5% -> 17% -> 100%. Use fixed +2% per page. - The 90% report used to fire before the new-article content batch finished (and before it even started when there was nothing new), hiding the whole 30-88 content range. Move it after the content job completes; report 45% after feed/group upsert and 60% when there is nothing new to fetch. - PullToSyncIndicator now eases the displayed percent toward the real progress so discrete reports render as a continuous count-up. --- .../domain/service/GoogleReaderRssService.kt | 75 +++++++++++-------- .../ui/page/home/flow/PullToSyncIndicator.kt | 18 ++++- 2 files changed, 60 insertions(+), 33 deletions(-) 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 0afbbe145..c229df791 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 @@ -283,7 +283,8 @@ constructor( val remoteUnreadIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(5 + (7 * page / 20).coerceAtMost(7)) }, + // 每页固定 +2%,page 从 1 开始;避免整数除法截断导致进度长时间不动 + onPage = { page -> reportProgress(5 + (2 * page).coerceAtMost(7)) }, ) { googleReaderAPI.getUnreadItemIds(continuationId = it) } .map { it.shortId } .toSet() @@ -291,7 +292,7 @@ constructor( val remoteStarredIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(12 + (4 * page / 20).coerceAtMost(4)) }, + onPage = { page -> reportProgress(12 + (2 * page).coerceAtMost(4)) }, ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() @@ -301,7 +302,7 @@ constructor( val remoteReadIds = async { // 已读 id 列表通常最大(近一月),占较多进度空间 fetchItemIdsAndContinue( - onPage = { page -> reportProgress(16 + (14 * page / 20).coerceAtMost(14)) }, + onPage = { page -> reportProgress(16 + (2 * page).coerceAtMost(14)) }, ) { googleReaderAPI.getReadItemIds( since = lastMonthAt, @@ -472,36 +473,39 @@ constructor( groupDao.insertOrUpdate(remoteGroups.await()) feedDao.insertOrUpdate(remoteFeeds.await()) + reportProgress(45) val notificationFeeds = feedDao.queryNotificationEnabled(accountId).associateBy { it.id } val notificationFeedIds = notificationFeeds.keys val articlesToNotify = mutableListOf
() - if (deferredList.isNotEmpty()) { - val contentTotal = deferredList.size - var contentDone = 0 - launch { - whileSelect { - for (deferred in deferredList) { - deferred.onAwait { - contentDone++ - reportProgress( - 30 + (60 * contentDone / contentTotal).coerceAtMost(60) - ) - articleDao.insertList(it) - articlesToNotify.addAll( - it.fastFilter { - it.isUnread && notificationFeedIds.contains(it.feedId) - } - ) - deferredList.remove(deferred) - deferredList.isNotEmpty() + val contentJob = + if (deferredList.isNotEmpty()) { + val contentTotal = deferredList.size + var contentDone = 0 + val job = + launch { + whileSelect { + for (deferred in deferredList) { + deferred.onAwait { + contentDone++ + reportProgress( + 30 + (60 * contentDone / contentTotal).coerceAtMost(60) + ) + 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 } @@ -511,10 +515,16 @@ constructor( } } } - } else { - reportProgress(90) - } + job + } else { + // 没有新文章内容要抓取时,也逐步上报而不是直接跳到 90 + reportProgress(60) + null + } + // 等新文章内容全部抓取完成后再报 90,避免过早的 90 把内容阶段的 + // 中间进度(30..88)全部吞掉 + contentJob?.join() reportProgress(90) // 8. Remove orphaned groups and feeds, after synchronizing the @@ -580,7 +590,7 @@ constructor( val remoteUnreadIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(22 + (4 * page / 20).coerceAtMost(4)) }, + onPage = { page -> reportProgress(22 + (2 * page).coerceAtMost(4)) }, ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), @@ -593,9 +603,9 @@ constructor( } val remoteAllIds = async { - // 全部 id 列表一般最大,占大头进度 + // 全部 id 列表一般最大,占大头进度;每页 +2%,page 从 1 开始 fetchItemIdsAndContinue( - onPage = { page -> reportProgress(5 + (17 * page / 20).coerceAtMost(17)) }, + onPage = { page -> reportProgress(5 + (2 * page).coerceAtMost(17)) }, ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), @@ -609,7 +619,7 @@ constructor( val remoteStarredIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(26 + (4 * page / 20).coerceAtMost(4)) }, + onPage = { page -> reportProgress(26 + (2 * page).coerceAtMost(4)) }, ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() @@ -626,7 +636,7 @@ constructor( unreadIds = remoteUnreadIds.await(), starredIds = remoteStarredIds.await(), ) - reportProgress(88) + reportProgress(70) if (feed.isNotification) { val articlesToNotify = items.fastFilter { it.isUnread } @@ -697,6 +707,7 @@ constructor( } articleDao.insert(*items.toTypedArray()) + reportProgress(90) reportProgress(100) Timber.i("onCompletion: ${System.currentTimeMillis() - preTime}") 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 950a5fd0d..42cd96a20 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 @@ -34,6 +34,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 @@ -61,6 +62,21 @@ fun BoxScope.PullToSyncIndicator( var showIndeterminateIndicator by remember { mutableStateOf(isSyncing) } + // 显示用百分比:向真实进度平滑爬升,让离散的上报点(如 7% → 14% → 90%) + // 在 UI 上呈现为连续的中间过程 + var displayedPercent by remember { mutableStateOf(progress ?: 0) } + LaunchedEffect(progress) { + val target = progress + if (target != null && target in 0..100) { + if (displayedPercent > target) displayedPercent = target + while (displayedPercent < target) { + val step = ((target - displayedPercent) / 4).coerceAtLeast(1) + displayedPercent = (displayedPercent + step).coerceAtMost(target) + delay(70) + } + } + } + val isSyncingFlow = snapshotFlow { isSyncing } val offsetSpec = remember { @@ -168,7 +184,7 @@ fun BoxScope.PullToSyncIndicator( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "$percent%", + text = "$displayedPercent%", color = MaterialTheme.colorScheme.onPrimaryFixedVariant, style = MaterialTheme.typography.labelMedium, ) From a1c5ead23561ab37e7806c21a237e532b21ac4a6 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 04:47:56 +0000 Subject: [PATCH 20/23] fix: report progress during the slow push-read stage; slow UI crawl as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh stalled around 20% because the longest real work — pushing locally-read ids back to the remote via editTag chunks (and the subscription list fetch) — reported no progress, and everything after it (45/60/90/100) fired within a moment at the end. Report per-chunk progress for the push (40..55 whole-account, 72..80 per-feed), add 38% after the subscription list resolves, and move 90% behind the orphan cleanup. PullToSyncIndicator now crawls the displayed percent up slowly (cap 96) whenever real progress is stalled, so the number never sits frozen mid-sync. --- .../domain/service/GoogleReaderRssService.kt | 28 ++++++++++++++----- .../ui/page/home/flow/PullToSyncIndicator.kt | 28 ++++++++++++++----- 2 files changed, 42 insertions(+), 14 deletions(-) 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 c229df791..00cf94559 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 @@ -384,7 +384,9 @@ constructor( // 推送失败无需处理:下一次同步仍会命中交集并重试,直到远端收敛。 val toBePushedRead = localReadIds.intersect(remoteUnreadIds.await()).toList() - toBePushedRead.chunked(500).forEach { + val chunks = toBePushedRead.chunked(500) + // 历史积压时推送是同步里最耗时的网络步骤,按 chunk 上报进度(40..55) + chunks.forEachIndexed { index, it -> runCatching { googleReaderAPI.editTag( itemIds = it, @@ -392,6 +394,10 @@ constructor( unmark = null, ) } + reportProgress(40 + (15 * (index + 1) / chunks.size).coerceAtMost(15)) + } + if (chunks.isNotEmpty()) { + reportProgress(55) } } @@ -471,7 +477,9 @@ constructor( .also { feedDao.update(*it.toTypedArray()) } } - groupDao.insertOrUpdate(remoteGroups.await()) + val groupsResolved = remoteGroups.await() + reportProgress(38) + groupDao.insertOrUpdate(groupsResolved) feedDao.insertOrUpdate(remoteFeeds.await()) reportProgress(45) @@ -522,10 +530,10 @@ constructor( null } - // 等新文章内容全部抓取完成后再报 90,避免过早的 90 把内容阶段的 + // 等新文章内容全部抓取完成后再进入收尾,避免过早报高值把内容阶段的 // 中间进度(30..88)全部吞掉 contentJob?.join() - reportProgress(90) + reportProgress(70) // 8. Remove orphaned groups and feeds, after synchronizing the // starred/un-starred @@ -538,6 +546,7 @@ constructor( .filter { it.id !in remoteFeeds.await().map { feed -> feed.id } } .forEach { super.deleteFeed(it, true) } + reportProgress(90) reportProgress(100) accountService.update(account.copy(updateAt = Date())) ListenableWorker.Result.success() @@ -664,9 +673,10 @@ constructor( launch { // 本地已读但远端仍为未读:推读远端,本地保持已读(参见整账户同步的说明) - val toBePushedReadIds = - localReadIds.intersect(remoteUnreadIds.await()).map { it.dollarLast() }.toList() - toBePushedReadIds.chunked(500).forEach { + val chunks = + localReadIds.intersect(remoteUnreadIds.await()).map { it.dollarLast() } + .chunked(500) + chunks.forEachIndexed { index, it -> runCatching { googleReaderAPI.editTag( itemIds = it, @@ -674,6 +684,10 @@ constructor( unmark = null, ) } + reportProgress(72 + (8 * (index + 1) / chunks.size).coerceAtMost(8)) + } + if (chunks.isNotEmpty()) { + reportProgress(80) } } 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 42cd96a20..f7ead7c73 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 @@ -23,6 +23,7 @@ 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 @@ -62,17 +63,30 @@ fun BoxScope.PullToSyncIndicator( var showIndeterminateIndicator by remember { mutableStateOf(isSyncing) } - // 显示用百分比:向真实进度平滑爬升,让离散的上报点(如 7% → 14% → 90%) - // 在 UI 上呈现为连续的中间过程 + // 显示用百分比:向真实进度平滑爬升;真实进度长时间停滞时缓慢兜底爬升 + // (约 0.7s +1%,封顶 96),避免同步中段停住不动、结束时瞬间冲到 100 var displayedPercent by remember { mutableStateOf(progress ?: 0) } - LaunchedEffect(progress) { - val target = progress + 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 - while (displayedPercent < target) { - val step = ((target - displayedPercent) / 4).coerceAtLeast(1) - displayedPercent = (displayedPercent + step).coerceAtMost(target) + } else { + displayedPercent = 0 + } + while (true) { + val real = currentProgress + if (real != null && real in 0..100 && displayedPercent < real) { + val step = ((real - displayedPercent) / 4).coerceAtLeast(1) + displayedPercent = (displayedPercent + step).coerceAtMost(real) delay(70) + } else if (displayedPercent < 96) { + displayedPercent += 1 + delay(700) + } else { + delay(300) } } } From 70498bf41e2fd3368cf23d2606a285daa9846702 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 05:26:02 +0000 Subject: [PATCH 21/23] feat: request-count based sync progress with dynamic budget reallocation Replace the fixed hard-coded percentage ranges with a dynamic budget planner: every real network round-trip reports a step. Phase 1 (id list pagination + subscription list, whose totals are unknown up front) steps +1% per page capped at 32; when the totals become known (content batches, push chunks, cleanup), the remaining budget is reallocated across those exact work units so 100% lands exactly when the sync truly finishes. Remove the fake slow crawl from PullToSyncIndicator; the displayed percent only eases toward real progress and freezes when the backend stalls. --- .../domain/service/GoogleReaderRssService.kt | 141 ++++++++++++------ .../ui/page/home/flow/PullToSyncIndicator.kt | 14 +- 2 files changed, 104 insertions(+), 51 deletions(-) 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 00cf94559..63092f48c 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 @@ -271,7 +272,7 @@ constructor( } val googleReaderAPI = getGoogleReaderAPI() googleReaderAPI.refreshCredentialsIfNeeded() - reportProgress(5) + val planner = SyncProgressPlanner(::reportProgress) val lastMonthAt = Calendar.getInstance() .apply { @@ -283,8 +284,8 @@ constructor( val remoteUnreadIds = async { fetchItemIdsAndContinue( - // 每页固定 +2%,page 从 1 开始;避免整数除法截断导致进度长时间不动 - onPage = { page -> reportProgress(5 + (2 * page).coerceAtMost(7)) }, + // 每次分页网络往返 +1%(页数未知时用封顶预算,见 SyncProgressPlanner) + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getUnreadItemIds(continuationId = it) } .map { it.shortId } .toSet() @@ -292,7 +293,7 @@ constructor( val remoteStarredIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(12 + (2 * page).coerceAtMost(4)) }, + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() @@ -300,9 +301,9 @@ constructor( val isFreshRss = account.type.id == FreshRSS.id val remoteReadIds = async { - // 已读 id 列表通常最大(近一月),占较多进度空间 + // 已读 id 列表通常最大(近一月),同样按页计步 fetchItemIdsAndContinue( - onPage = { page -> reportProgress(16 + (2 * page).coerceAtMost(14)) }, + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getReadItemIds( since = lastMonthAt, @@ -384,9 +385,8 @@ constructor( // 推送失败无需处理:下一次同步仍会命中交集并重试,直到远端收敛。 val toBePushedRead = localReadIds.intersect(remoteUnreadIds.await()).toList() - val chunks = toBePushedRead.chunked(500) - // 历史积压时推送是同步里最耗时的网络步骤,按 chunk 上报进度(40..55) - chunks.forEachIndexed { index, it -> + // 历史积压时推送是同步里最耗时的网络步骤,每批推送完成报一步 + toBePushedRead.chunked(500).forEach { runCatching { googleReaderAPI.editTag( itemIds = it, @@ -394,10 +394,7 @@ constructor( unmark = null, ) } - reportProgress(40 + (15 * (index + 1) / chunks.size).coerceAtMost(15)) - } - if (chunks.isNotEmpty()) { - reportProgress(55) + planner.phase2Step() } } @@ -454,7 +451,6 @@ constructor( scope = this, ) .toMutableList() - reportProgress(30) val remoteGroups = async { groupWithFeedsMap.await().keys.toList() } val remoteFeeds = async { groupWithFeedsMap.await().values.flatten() } @@ -478,10 +474,15 @@ constructor( } val groupsResolved = remoteGroups.await() - reportProgress(38) + planner.phase1Step(units = 2) // 订阅列表:1 次网络请求 groupDao.insertOrUpdate(groupsResolved) feedDao.insertOrUpdate(remoteFeeds.await()) - reportProgress(45) + + // 进入阶段 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 } @@ -490,17 +491,12 @@ constructor( val contentJob = if (deferredList.isNotEmpty()) { - val contentTotal = deferredList.size - var contentDone = 0 val job = launch { whileSelect { for (deferred in deferredList) { deferred.onAwait { - contentDone++ - reportProgress( - 30 + (60 * contentDone / contentTotal).coerceAtMost(60) - ) + planner.phase2Step() articleDao.insertList(it) articlesToNotify.addAll( it.fastFilter { @@ -525,15 +521,12 @@ constructor( } job } else { - // 没有新文章内容要抓取时,也逐步上报而不是直接跳到 90 - reportProgress(60) + // 没有新文章内容要抓取 null } - // 等新文章内容全部抓取完成后再进入收尾,避免过早报高值把内容阶段的 - // 中间进度(30..88)全部吞掉 + // 等新文章内容全部抓取完成后进入收尾 contentJob?.join() - reportProgress(70) // 8. Remove orphaned groups and feeds, after synchronizing the // starred/un-starred @@ -546,8 +539,8 @@ constructor( .filter { it.id !in remoteFeeds.await().map { feed -> feed.id } } .forEach { super.deleteFeed(it, true) } - reportProgress(90) - reportProgress(100) + planner.phase2Step() // 收尾单元 1:孤儿清理完成 + planner.phase2Step() // 收尾单元 2:全部完成 → 100% accountService.update(account.copy(updateAt = Date())) ListenableWorker.Result.success() } catch (e: Exception) { @@ -574,6 +567,7 @@ constructor( "account type is invalid" } val googleReaderAPI = getGoogleReaderAPI() + val planner = SyncProgressPlanner(::reportProgress) val feed = feedDao.queryById(feedId)!! @@ -599,7 +593,7 @@ constructor( val remoteUnreadIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(22 + (2 * page).coerceAtMost(4)) }, + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), @@ -612,9 +606,9 @@ constructor( } val remoteAllIds = async { - // 全部 id 列表一般最大,占大头进度;每页 +2%,page 从 1 开始 + // 全部 id 列表一般最大,占大头进度;同样按页计步 fetchItemIdsAndContinue( - onPage = { page -> reportProgress(5 + (2 * page).coerceAtMost(17)) }, + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getItemIdsForFeed( feedId = feedId.dollarLast(), @@ -628,14 +622,14 @@ constructor( val remoteStarredIds = async { fetchItemIdsAndContinue( - onPage = { page -> reportProgress(26 + (2 * page).coerceAtMost(4)) }, + onPage = { planner.phase1Step() }, ) { googleReaderAPI.getStarredItemIds(continuationId = it) } .map { it.shortId } .toSet() } val toFetch = remoteAllIds.await() - localIds - reportProgress(30) + val contentUnits = (toFetch.size + 99) / 100 val items = fetchItemsContents( @@ -645,7 +639,12 @@ constructor( unreadIds = remoteUnreadIds.await(), starredIds = remoteStarredIds.await(), ) - reportProgress(70) + + // 内容整体拉完,一次性走完内容单元;剩余预算按推送批数 + 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 } @@ -676,7 +675,7 @@ constructor( val chunks = localReadIds.intersect(remoteUnreadIds.await()).map { it.dollarLast() } .chunked(500) - chunks.forEachIndexed { index, it -> + chunks.forEach { runCatching { googleReaderAPI.editTag( itemIds = it, @@ -684,10 +683,7 @@ constructor( unmark = null, ) } - reportProgress(72 + (8 * (index + 1) / chunks.size).coerceAtMost(8)) - } - if (chunks.isNotEmpty()) { - reportProgress(80) + planner.phase2Step() } } @@ -721,8 +717,8 @@ constructor( } articleDao.insert(*items.toTypedArray()) - reportProgress(90) - reportProgress(100) + planner.phase2Step() // 收尾单元 1:入库完成 + planner.phase2Step() // 收尾单元 2:全部完成 → 100% Timber.i("onCompletion: ${System.currentTimeMillis() - preTime}") ListenableWorker.Result.success() @@ -930,3 +926,64 @@ constructor( ) } } + +/** + * 动态预算进度计划器(GR 同步用)。 + * + * 进度 = 已完成请求单元 / 估算总单元,每一步都对应真实完成的网络/DB 工作: + * - 阶段 1(id 列表分页 + 订阅列表,总页数未知):每次网络往返 +1%, + * 封顶 [PHASE1_BUDGET];页数超过封顶时冻结在封顶值,等阶段 1 结束重分配。 + * - 阶段 2(新文章内容、已读推送、收尾):此时总量已全部已知,把剩余预算 + * 按已知的请求单元数平均分配,保证 100% 落在同步真实完成的时刻, + * 不做任何假进度。 + */ +private class SyncProgressPlanner( + private val report: (Int) -> Unit, +) { + /** 阶段 1 预算:id 分页每页 1%(最多 30 页)+ 订阅列表 2% */ + private val phase1Budget = 32 + 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 单元,订阅列表一次 = 2 单元) */ + fun phase1Step(units: Int = 1) { + if (phase2) return + val done = phase1Units.addAndGet(units) + publish(minOf(phase1Budget, done)) + } + + /** 进入阶段 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/ui/page/home/flow/PullToSyncIndicator.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/PullToSyncIndicator.kt index f7ead7c73..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 @@ -63,13 +63,12 @@ fun BoxScope.PullToSyncIndicator( var showIndeterminateIndicator by remember { mutableStateOf(isSyncing) } - // 显示用百分比:向真实进度平滑爬升;真实进度长时间停滞时缓慢兜底爬升 - // (约 0.7s +1%,封顶 96),避免同步中段停住不动、结束时瞬间冲到 100 + // 显示用百分比:向真实进度快速平滑(约 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 @@ -79,14 +78,11 @@ fun BoxScope.PullToSyncIndicator( while (true) { val real = currentProgress if (real != null && real in 0..100 && displayedPercent < real) { - val step = ((real - displayedPercent) / 4).coerceAtLeast(1) + val step = ((real - displayedPercent) / 2).coerceAtLeast(1) displayedPercent = (displayedPercent + step).coerceAtMost(real) - delay(70) - } else if (displayedPercent < 96) { - displayedPercent += 1 - delay(700) + delay(60) } else { - delay(300) + delay(150) } } } From 190b8c26a80ca662d62ad8161498df1e514f2765 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 05:39:33 +0000 Subject: [PATCH 22/23] fix: raise phase-1 progress step to 2% per id page, cap 96% The account's id lists span 30+ pages but phase 1 was capped at 32%, so the percentage froze at 32% while the remaining id pages were still being fetched (the actual bulk of sync time), then jumped to 100% at the end. 2% per page with a 96% cap keeps the number advancing through the entire id-list phase for typical account sizes. --- .../reader/domain/service/GoogleReaderRssService.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 63092f48c..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 @@ -474,7 +474,7 @@ constructor( } val groupsResolved = remoteGroups.await() - planner.phase1Step(units = 2) // 订阅列表:1 次网络请求 + planner.phase1Step() // 订阅列表:1 次网络请求 groupDao.insertOrUpdate(groupsResolved) feedDao.insertOrUpdate(remoteFeeds.await()) @@ -940,8 +940,11 @@ constructor( private class SyncProgressPlanner( private val report: (Int) -> Unit, ) { - /** 阶段 1 预算:id 分页每页 1%(最多 30 页)+ 订阅列表 2% */ - private val phase1Budget = 32 + /** 阶段 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 @@ -951,11 +954,11 @@ private class SyncProgressPlanner( @Volatile private var current = 0 - /** 阶段 1:完成一次网络往返(id 列表一页 = 1 单元,订阅列表一次 = 2 单元) */ + /** 阶段 1:完成一次网络往返(id 列表一页 = 1 单元,订阅列表一次 = 1 单元) */ fun phase1Step(units: Int = 1) { if (phase2) return val done = phase1Units.addAndGet(units) - publish(minOf(phase1Budget, done)) + publish(minOf(phase1Budget, done * phase1UnitPercent)) } /** 进入阶段 2:剩余预算按 [totalUnits] 个已知工作单元平均分配 */ From 2bf2f20e530df26744c2892c94436041b1562ba1 Mon Sep 17 00:00:00 2001 From: Left024 Date: Sat, 5 Sep 2026 05:59:20 +0000 Subject: [PATCH 23/23] fix: place feeds page pull-to-sync indicator at top like flow page The indicator was composed inside the Scaffold content box, so it was positioned below the top app bar on the feeds page. Move it to an outer Box over the scaffold so it anchors to the top of the screen exactly like on the article list page. --- .../reader/ui/page/home/feeds/FeedsPage.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 fde0c2922..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 @@ -220,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 = { @@ -390,14 +391,6 @@ fun FeedsPage( ) } } - // 下拉同步指示:与文章信息流页同款(下拉进度 / 同步中转圈+百分比) - currentPullToLoadState?.let { - PullToSyncIndicator( - pullToLoadState = it, - isSyncing = isSyncing, - progress = if (isSyncing) syncProgress else null, - ) - } } }, bottomBar = { @@ -420,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)