Jelajahi Sumber

feat: 阅读菜单的云端进度选项不显示的问题

HapeLee 1 bulan lalu
induk
melakukan
bd28fd2bc1

+ 20 - 1
app/src/main/java/io/legado/app/data/repository/WebDavReadingProgressRepository.kt

@@ -4,13 +4,30 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
+import io.legado.app.help.WebDavConfigState
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
 
 
 class WebDavReadingProgressRepository : ReadingProgressGateway {
 class WebDavReadingProgressRepository : ReadingProgressGateway {
 
 
+    /**
+     * 菜单可见性只看「本地是否配了 WebDAV 账号」。
+     *
+     * 此前返回 `AppWebDav.isOk`(本次进程内网络初始化成功),会把「服务暂时连不上」
+     * 和「没配置」混为一谈,导致进度同步入口在初始化失败时永久消失。
+     */
     override val isConfigured: Boolean
     override val isConfigured: Boolean
-        get() = AppWebDav.isOk
+        get() = AppWebDav.isConfigured
+
+    override val isConfiguredFlow: Flow<Boolean> = AppWebDav.configState
+        .map { it == WebDavConfigState.Ready }
+
+    override suspend fun ensureConfigured() {
+        AppWebDav.ensureConfigured()
+    }
 
 
     override suspend fun getProgress(name: String, author: String): ReadingProgress? {
     override suspend fun getProgress(name: String, author: String): ReadingProgress? {
+        AppWebDav.ensureConfigured()
         return AppWebDav.getBookProgress(name, author)?.let {
         return AppWebDav.getBookProgress(name, author)?.let {
             ReadingProgress(
             ReadingProgress(
                 name = it.name,
                 name = it.name,
@@ -24,6 +41,8 @@ class WebDavReadingProgressRepository : ReadingProgressGateway {
     }
     }
 
 
     override suspend fun uploadProgress(progress: ReadingProgress): Long? {
     override suspend fun uploadProgress(progress: ReadingProgress): Long? {
+        // 与 getProgress 同理:菜单可见性只证明「配了账号」,真正读写前补一次初始化。
+        AppWebDav.ensureConfigured()
         val uploadTime = System.currentTimeMillis()
         val uploadTime = System.currentTimeMillis()
         val uploaded = AppWebDav.uploadBookProgress(
         val uploaded = AppWebDav.uploadBookProgress(
             BookProgress(
             BookProgress(

+ 12 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadingProgressGateway.kt

@@ -1,10 +1,22 @@
 package io.legado.app.domain.gateway
 package io.legado.app.domain.gateway
 
 
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.ReadingProgress
+import kotlinx.coroutines.flow.Flow
 
 
 interface ReadingProgressGateway {
 interface ReadingProgressGateway {
     val isConfigured: Boolean
     val isConfigured: Boolean
 
 
+    /**
+     * 云端进度同步可用性的变更流。
+     *
+     * 只读一次 [isConfigured] 会拿到快照:后端的网络初始化晚到或失败时,
+     * UI 侧的状态会永久停留在「未配置」。故显式建模成流。
+     */
+    val isConfiguredFlow: Flow<Boolean>
+
+    /** 补齐缺失的云端配置(幂等;已初始化成功时为 no-op)。 */
+    suspend fun ensureConfigured()
+
     suspend fun getProgress(name: String, author: String): ReadingProgress?
     suspend fun getProgress(name: String, author: String): ReadingProgress?
     suspend fun uploadProgress(progress: ReadingProgress): Long?
     suspend fun uploadProgress(progress: ReadingProgress): Long?
 }
 }

+ 10 - 0
app/src/main/java/io/legado/app/domain/usecase/GetReadingProgressUseCase.kt

@@ -2,6 +2,7 @@ package io.legado.app.domain.usecase
 
 
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.ReadingProgress
+import kotlinx.coroutines.flow.Flow
 
 
 class GetReadingProgressUseCase(
 class GetReadingProgressUseCase(
     private val readingProgressGateway: ReadingProgressGateway
     private val readingProgressGateway: ReadingProgressGateway
@@ -10,6 +11,15 @@ class GetReadingProgressUseCase(
     val isConfigured: Boolean
     val isConfigured: Boolean
         get() = readingProgressGateway.isConfigured
         get() = readingProgressGateway.isConfigured
 
 
+    /** 云端进度同步可用性变更流;UI 侧状态不得只依赖 [isConfigured] 的一次性快照。 */
+    val isConfiguredFlow: Flow<Boolean>
+        get() = readingProgressGateway.isConfiguredFlow
+
+    /** 补齐缺失的云端配置(幂等;已初始化成功时为 no-op)。 */
+    suspend fun ensureConfigured() {
+        readingProgressGateway.ensureConfigured()
+    }
+
     suspend fun execute(name: String, author: String): ReadingProgress? {
     suspend fun execute(name: String, author: String): ReadingProgress? {
         return readingProgressGateway.getProgress(name, author)
         return readingProgressGateway.getProgress(name, author)
     }
     }

+ 93 - 12
app/src/main/java/io/legado/app/help/AppWebDav.kt

@@ -29,12 +29,38 @@ import io.legado.app.utils.normalizeFileName
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.sync.withLock
 import splitties.init.appCtx
 import splitties.init.appCtx
 import org.koin.core.context.GlobalContext
 import org.koin.core.context.GlobalContext
 import java.io.File
 import java.io.File
 
 
+/**
+ * WebDAV 配置就绪状态(本地配置 + 本次进程的网络初始化结果)。
+ *
+ * UI 只消费这个状态来开关「拉取/覆盖云端进度」这类入口,
+ * 不再直接读一次性的 [AppWebDav.authorization]。
+ */
+sealed interface WebDavConfigState {
+    /** 尚未取得过状态(App 刚启动,[AppWebDav.upConfig] 还没跑)。 */
+    data object Idle : WebDavConfigState
+
+    /** 本地没有账号或密码。 */
+    data object Unconfigured : WebDavConfigState
+
+    /** 有配置,正在做网络初始化(校验授权 + 建目录)。 */
+    data object Preparing : WebDavConfigState
+
+    /** 本次进程内初始化成功,可真正读写云端。 */
+    data object Ready : WebDavConfigState
+
+    /** 网络初始化失败(账号密码错、服务不支持 MKCOL、无网络等)。 */
+    data class Failed(val message: String?) : WebDavConfigState
+}
+
 /**
 /**
  * webDav初始化会访问网络,不要放到主线程
  * webDav初始化会访问网络,不要放到主线程
  */
  */
@@ -49,6 +75,15 @@ object AppWebDav {
     private val configMutex = Mutex()
     private val configMutex = Mutex()
     private var appliedConfig: AppliedWebDavConfig? = null
     private var appliedConfig: AppliedWebDavConfig? = null
 
 
+    /**
+     * 云端配置(本地账号密码)的就绪状态,供 UI 观测。
+     *
+     * [upConfig] 会访问网络,成功与否不能靠「读一次 [isOk]」来判断:UI 的取值是快照,
+     * 初始化晚到或失败时菜单会永远消失。故把状态建模成 flow,由 UI 订阅。
+     */
+    private val _configState = MutableStateFlow<WebDavConfigState>(WebDavConfigState.Idle)
+    val configState: StateFlow<WebDavConfigState> = _configState.asStateFlow()
+
     @Volatile
     @Volatile
     var authorization: Authorization? = null
     var authorization: Authorization? = null
         private set
         private set
@@ -56,6 +91,19 @@ object AppWebDav {
     @Volatile
     @Volatile
     var defaultBookWebDav: RemoteBookWebDav? = null
     var defaultBookWebDav: RemoteBookWebDav? = null
 
 
+    /**
+     * 是否已配置 WebDAV 账号(纯本地判断,不触发网络)。
+     *
+     * 与上游一致:菜单可见性只看「配置了没」,网络校验属于 [upConfig] 的职责。
+     * 此前这里返回 `authorization != null`,等于要求本次进程内已经完成过一次
+     * 成功的网络初始化 + 目录创建,初始化失败就会让整个进度同步入口永久消失。
+     */
+    val isConfigured: Boolean
+        get() = with(backupGateway.currentSettings) {
+            webDavAccount.trim().isNotEmpty() && webDavPassword.isNotEmpty()
+        }
+
+    /** 本次进程内初始化是否已成功(能真正读写云端)。 */
     val isOk get() = authorization != null
     val isOk get() = authorization != null
 
 
     val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
     val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
@@ -81,27 +129,56 @@ object AppWebDav {
                 password = backupGateway.currentSettings.webDavPassword,
                 password = backupGateway.currentSettings.webDavPassword,
                 dir = backupGateway.currentSettings.webDavDir,
                 dir = backupGateway.currentSettings.webDavDir,
             )
             )
+            // 配置没变时状态只会是 Ready 或 Unconfigured,两者都无需重新网络初始化。
             if (appliedConfig == config) return
             if (appliedConfig == config) return
 
 
+            if (config.account.isEmpty() || config.password.isEmpty()) {
+                appliedConfig = config
+                authorization = null
+                defaultBookWebDav = null
+                _configState.value = WebDavConfigState.Unconfigured
+                return
+            }
+
+            _configState.value = WebDavConfigState.Preparing
             kotlin.runCatching {
             kotlin.runCatching {
+                val mAuthorization = Authorization(config.account, config.password)
+                checkAuthorization(mAuthorization)
+                WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
+                WebDav(bookProgressUrl, mAuthorization).makeAsDir()
+                WebDav(exportsWebDavUrl, mAuthorization).makeAsDir()
+                WebDav(bgWebDavUrl, mAuthorization).makeAsDir()
+                val rootBooksUrl = "${rootWebDavUrl}books/"
+                defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
+                authorization = mAuthorization
+                _configState.value = WebDavConfigState.Ready
+            }.onFailure { e ->
+                // 不再静默:此前异常被吞掉,UI 上表现为进度同步入口无声消失。
                 authorization = null
                 authorization = null
                 defaultBookWebDav = null
                 defaultBookWebDav = null
-                if (config.account.isNotEmpty() && config.password.isNotEmpty()) {
-                    val mAuthorization = Authorization(config.account, config.password)
-                    checkAuthorization(mAuthorization)
-                    WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
-                    WebDav(bookProgressUrl, mAuthorization).makeAsDir()
-                    WebDav(exportsWebDavUrl, mAuthorization).makeAsDir()
-                    WebDav(bgWebDavUrl, mAuthorization).makeAsDir()
-                    val rootBooksUrl = "${rootWebDavUrl}books/"
-                    defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
-                    authorization = mAuthorization
-                }
-                appliedConfig = config
+                _configState.value = WebDavConfigState.Failed(e.localizedMessage)
+                AppLog.put("webDav初始化失败\n${e.localizedMessage}", e)
             }
             }
+            // appliedConfig 只在成功时推进,失败留白以便下次设置变化或显式调用时重试。
+            if (_configState.value == WebDavConfigState.Ready) appliedConfig = config
         }
         }
     }
     }
 
 
+    /**
+     * 进度同步等入口在打开菜单时调用:配置齐备但本次进程尚未初始化成功时补一次。
+     *
+     * 等价于上游 `onPrepareOptionsMenu` 每次开菜单都重算 `AppWebDav.isOk` 的效果,
+     * 但把网络动作留在 IO,UI 只读 [configState]。
+     */
+    suspend fun ensureConfigured() {
+        if (isOk) return
+        if (!isConfigured) {
+            _configState.value = WebDavConfigState.Unconfigured
+            return
+        }
+        upConfig()
+    }
+
     private data class AppliedWebDavConfig(
     private data class AppliedWebDavConfig(
         val url: String,
         val url: String,
         val account: String,
         val account: String,
@@ -188,6 +265,10 @@ object AppWebDav {
             val auth = Authorization(account, password)
             val auth = Authorization(account, password)
             checkAuthorization(auth)
             checkAuthorization(auth)
 
 
+            // 上游 check() 会真正建立 configuredWebDav;这里同样落一次配置,
+            // 否则「测试同步」通过后 authorization 仍是空,阅读界面的进度同步入口不会出现。
+            upConfig()
+
             appCtx.toastOnUi("WebDAV 服务可用")
             appCtx.toastOnUi("WebDAV 服务可用")
             true
             true
         }.getOrElse {
         }.getOrElse {

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarFloating.kt

@@ -318,8 +318,8 @@ private fun OverflowDropdownMenu(
             PillDivider()
             PillDivider()
         }
         }
 
 
-        // 进度同步
-        if (state.isReadingProgressSyncConfigured) {
+        // 进度同步(与上游一致:在书架 + 已配云端才显示)
+        if (state.inBookshelf && state.isReadingProgressSyncConfigured) {
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.get_book_progress),
                 text = stringResource(R.string.get_book_progress),
                 leadingIcon = menuIcon(Icons.Default.Sync),
                 leadingIcon = menuIcon(Icons.Default.Sync),

+ 29 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -611,6 +611,13 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
+    /** 云端进度同步域;投影目标 isReadingProgressSyncConfigured 仍留在 UiState 供菜单读。 */
+    private val readingProgressSyncDelegate by lazy {
+        ReadingProgressSyncDelegate(viewModelScope, getReadingProgressUseCase) { configured ->
+            _uiState.update { it.copy(isReadingProgressSyncConfigured = configured) }
+        }
+    }
+
     init {
     init {
         // 订阅必须早于 attach():会话事件用 tryEmit 投递,没有订阅者会被丢弃。
         // 订阅必须早于 attach():会话事件用 tryEmit 投递,没有订阅者会被丢弃。
         // viewModelScope 是 Main.immediate,VM 在主线程构造,故 launch 会同步跑到
         // viewModelScope 是 Main.immediate,VM 在主线程构造,故 launch 会同步跑到
@@ -622,6 +629,7 @@ class ReadBookViewModel(
         collectEventBus()
         collectEventBus()
         collectReaderSession()
         collectReaderSession()
         collectReadStyle()
         collectReadStyle()
+        readingProgressSyncDelegate.start()
         replaceRuleDelegate.start()
         replaceRuleDelegate.start()
     }
     }
 
 
@@ -744,17 +752,21 @@ class ReadBookViewModel(
                 openChapter(intent.index, intent.pos)
                 openChapter(intent.index, intent.pos)
             }
             }
             is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex)
             is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex)
-            is ReadBookIntent.ToggleMenu -> _uiState.update {
-                if (it.menuVisible) {
-                    readBookStyleConfigRepository.save()
-                    it.copy(menuState = ReadBookMenuState())
+            is ReadBookIntent.ToggleMenu -> {
+                if (_uiState.value.menuVisible) {
+                    _uiState.update {
+                        readBookStyleConfigRepository.save()
+                        it.copy(menuState = ReadBookMenuState())
+                    }
                 } else {
                 } else {
-                    it.copy(menuState = ReadBookMenuState(visible = true))
+                    ensureReadingProgressSyncConfigured()
+                    _uiState.update { it.copy(menuState = ReadBookMenuState(visible = true)) }
                 }
                 }
             }
             }
 
 
-            is ReadBookIntent.ShowMenu -> _uiState.update {
-                it.copy(menuState = ReadBookMenuState(visible = true))
+            is ReadBookIntent.ShowMenu -> {
+                ensureReadingProgressSyncConfigured()
+                _uiState.update { it.copy(menuState = ReadBookMenuState(visible = true)) }
             }
             }
 
 
             is ReadBookIntent.HideMenu -> _uiState.update {
             is ReadBookIntent.HideMenu -> _uiState.update {
@@ -1077,13 +1089,13 @@ class ReadBookViewModel(
                 }
                 }
             }
             }
 
 
-            is ReadBookIntent.MenuCoverProgress -> {
-                ReadBook.book?.let {
-                    ReadBook.uploadProgress(true) {
-                        _effects.tryEmit(
-                            ReadBookEffect.ShowToast(context.getString(R.string.upload_book_success))
-                        )
-                    }
+            is ReadBookIntent.MenuCoverProgress -> if (ReadBook.book != null) viewModelScope.launch {
+                // 覆盖云端进度直接走 AppWebDav,必须先等云端初始化结束,否则上传被静默丢弃
+                readingProgressSyncDelegate.ensureConfiguredNow()
+                ReadBook.uploadProgress(true) {
+                    _effects.tryEmit(
+                        ReadBookEffect.ShowToast(context.getString(R.string.upload_book_success))
+                    )
                 }
                 }
             }
             }
 
 
@@ -2143,6 +2155,9 @@ class ReadBookViewModel(
 
 
     fun isReadingProgressSyncConfigured(): Boolean = loadDelegate.isReadingProgressSyncConfigured()
     fun isReadingProgressSyncConfigured(): Boolean = loadDelegate.isReadingProgressSyncConfigured()
 
 
+    /** 打开阅读菜单时补齐云端配置(上游 `onPrepareOptionsMenu` 的重算时机)。 */
+    fun ensureReadingProgressSyncConfigured() = readingProgressSyncDelegate.ensureConfigured()
+
     suspend fun uploadBookProgress(book: Book) = loadDelegate.uploadBookProgress(book)
     suspend fun uploadBookProgress(book: Book) = loadDelegate.uploadBookProgress(book)
 
 
     fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) {
     fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) {

+ 55 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadingProgressSyncDelegate.kt

@@ -0,0 +1,55 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * 云端阅读进度同步域:让 [ReadBookUiState.isReadingProgressSyncConfigured] 反映**当前**
+ * 的云端可用性,而不是开书那一瞬间的快照。
+ *
+ * `AppWebDav` 的 WebDAV 初始化走网络(校验授权 + 建目录),只由 App 启动时的设置流触发。
+ * 开书早于初始化完成、或初始化失败一次,`syncFromReadBook` 投影出的 false 就没有任何路径
+ * 会被纠正——阅读界面的「拉取云端进度 / 覆盖云端进度」永久消失。上游是 View 菜单,
+ * `onPrepareOptionsMenu` 每次开菜单都重算 `AppWebDav.isOk`;Compose 侧没有这个时机,
+ * 故在这里订阅状态流,并在开菜单时补一次初始化。
+ *
+ * 与 [ReadReplaceRuleDelegate] 同形,自己开订阅,不需要宿主显式 `start`。
+ */
+class ReadingProgressSyncDelegate(
+    private val scope: CoroutineScope,
+    private val getReadingProgressUseCase: GetReadingProgressUseCase,
+    private val host: Host,
+) {
+
+    /** 把云端可用性写回 UiState(菜单可见性直接读它)。 */
+    fun interface Host {
+        fun setSyncConfigured(configured: Boolean)
+    }
+
+    /** 订阅云端可用性变更(StateFlow 会立刻回放当前值,晚订阅也不会漏掉初始态)。 */
+    fun start() {
+        scope.launch {
+            getReadingProgressUseCase.isConfiguredFlow.collect(host::setSyncConfigured)
+        }
+    }
+
+    /** 打开阅读菜单时补齐云端配置(上游 `onPrepareOptionsMenu` 的重算时机)。初始化走网络,故在 IO。 */
+    fun ensureConfigured() {
+        scope.launch(Dispatchers.IO) {
+            getReadingProgressUseCase.ensureConfigured()
+        }
+    }
+
+    /**
+     * 菜单点击路径用:必须**等**初始化结束再落真正的读写,否则首次点击会撞上
+     * `authorization` 仍为空而被静默丢弃(上传只返回 false,界面上没有任何反馈)。
+     */
+    suspend fun ensureConfiguredNow() {
+        withContext(Dispatchers.IO) {
+            getReadingProgressUseCase.ensureConfigured()
+        }
+    }
+}

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt

@@ -354,11 +354,11 @@ private fun moreActionSpecs(
         onClick = { onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading)) }),
         onClick = { onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading)) }),
     MoreActionSpec(
     MoreActionSpec(
         "get_progress", stringResource(R.string.get_book_progress), Icons.Default.Sync,
         "get_progress", stringResource(R.string.get_book_progress), Icons.Default.Sync,
-        applicable = state.isReadingProgressSyncConfigured,
+        applicable = state.inBookshelf && state.isReadingProgressSyncConfigured,
         onClick = { dispatch(ReadBookIntent.MenuGetProgress) }),
         onClick = { dispatch(ReadBookIntent.MenuGetProgress) }),
     MoreActionSpec(
     MoreActionSpec(
         "cover_progress", stringResource(R.string.cover_book_progress), Icons.Default.Sync,
         "cover_progress", stringResource(R.string.cover_book_progress), Icons.Default.Sync,
-        applicable = state.isReadingProgressSyncConfigured,
+        applicable = state.inBookshelf && state.isReadingProgressSyncConfigured,
         onClick = { dispatch(ReadBookIntent.MenuCoverProgress) }),
         onClick = { dispatch(ReadBookIntent.MenuCoverProgress) }),
     MoreActionSpec(
     MoreActionSpec(
         "bottom_button_config", stringResource(R.string.config_btn), Icons.Default.Settings,
         "bottom_button_config", stringResource(R.string.config_btn), Icons.Default.Settings,

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt

@@ -147,6 +147,7 @@ class BackupConfigViewModel(
             settingsGateway.update {
             settingsGateway.update {
                 it.copy(webDavAccount = dialog.account, webDavPassword = dialog.password)
                 it.copy(webDavAccount = dialog.account, webDavPassword = dialog.password)
             }
             }
+            // testWebDav() 内部会调 upConfig() 重建 authorization,故这里不需要额外触发。
             testWebDav()
             testWebDav()
         }
         }
     }
     }

+ 12 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -527,6 +527,18 @@ class ReadBookDomainSplitBoundaryTest {
                     "bookRepository.getChapterTitle",
                     "bookRepository.getChapterTitle",
                 ),
                 ),
             ),
             ),
+            // 云端进度同步域无自持状态:投影目标 isReadingProgressSyncConfigured 是菜单
+            // 可见性输入,仍在 UiState;靠 stateTypes 守「订阅云端可用性流 + 开菜单补
+            // 初始化不回流 VM」——这两件事一回流,入口就会重新变成「读一次快照」。
+            DomainSplit(
+                name = "云端进度同步",
+                delegateFile = "io/legado/app/ui/book/read/ReadingProgressSyncDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "isConfiguredFlow",
+                    "useCase.ensureConfigured",
+                ),
+            ),
         )
         )
 
 
         val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")
         val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")