Skip to content

fix(sync): recover credentials after secure storage failures - #617

Merged
cwuom merged 8 commits into
masterfrom
fix/sync-credential-recovery
Oct 31, 2026
Merged

cwuom merged 8 commits into
masterfrom
fix/sync-credential-recovery

Conversation

@cwuom

@cwuom cwuom commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

描述 / Description

Secure-storage failures could leave GitHub/WebDAV sync stuck in a cached fallback, while stale token-validation state prevented saving a replacement token. A late GitHub 401 could also erase newer credentials and repository settings.

  • Reopen the original encrypted preferences on retry without deleting unreadable configuration, and invalidate sync work created before recovery.
  • Bind token validation to the current input, confirm credential persistence, and guard configuration changes against stale asynchronous work.
  • Report local secure-storage failures explicitly and preserve saved favorites and existing sync configuration.
  • Keep backup settings reachable during storage outages, retain unknown or last-known preference values and pending choices, and retry safely.
  • Preserve saved playback history when secure deletion cleanup fails; refuse history deletion or lyric metadata changes whose durable sync metadata cannot be accessed.
  • Pause automatic GitHub sync on 401 through the captured configuration guard, retaining credentials and ignoring failures from replaced configurations.
  • Confirm durable WebDAV credential writes before reporting success, and reject stale preference editors without invalidating a recovered storage session.
  • Keep lyric-override subscriptions alive when secure storage is initially unavailable, then refresh after a confirmed commit without weakening strict reads or cancellation.

类型 / Type

  • Bug 修复 / Bug Fix
  • 新功能 / New Feature
  • 文档更新 / Documentation Update
  • 其他(请描述)/ Other (please describe):

修复或解决的问题 / Issues Fixed or Closed by This PR

Reported directly: token replacement could not recover sync, and the sync database version dialog could keep failing after retry. Opening backup settings or mutating playback history during a secure-storage outage could also crash. Background GitHub authentication failures could repeatedly schedule requests and notifications.

清单 / Checklist

  • 我已阅读并遵循贡献指南 / I have read and followed the contribution guidelines
  • 我已在本地测试这些更改 / I have tested these changes locally
  • 我已更新相关文档或注释(如适用) / I have updated relevant documentation or comments (if applicable)
  • 我确认本次更改没有破坏任何原有逻辑 / I confirm this change does not break existing behavior
  • 我确认本次更改保持向下兼容(如适用) / I confirm this change remains backward compatible (if applicable)
  • 我已运行必要的构建、测试或静态检查 / I have run the required build, tests, or static checks
  • 我确认没有提交密钥、Token 或其他敏感信息 / I confirm no secrets, tokens, or other sensitive information were committed
  • 涉及 UI 变更时,我已补充截图或录屏(如适用) / I have added screenshots or recordings for UI changes (if applicable)

其他信息 / Additional Information

Validation:

  • The complete CI Gradle gate passed locally on 370b4dfc0 with JDK 21: 15,207 JVM cases, 0 failures/errors and 7 skipped; lint, CRAP and dependency checks passed.

  • 75 focused history/backend/worker regressions passed after reproducing the failures. Coverage includes saved history and durable deletion preservation, recovery retries, typed exception boundaries, automatic-sync pause, stale 401 responses across storage instances and unavailable storage while pausing.

  • The directory-refresh regression passed in five independent executions (15 cases). The fixture waits for its own operation and waiter to be released before asserting state or restoring globals, using the original deadline and assertions.

  • 26 focused settings regressions passed, including unavailable reads, deferred legacy migration, partial preference snapshots, cancellation, recovery UI access, failed saves and import/read interleavings with a controlled IO dispatcher.

  • The settings-card color failure was reproduced in CI on 370b4dfc0. The test-only follow-up 16bc12f1e reads committed Activity-window pixels instead of a screenshot of the display, retaining all RGB/layout assertions and timeouts. Three independent API 35 ARM64 runs passed all 54 cases with no skips (swangle, 1080x2400, 420 dpi); debug/test APK builds and app lint passed.

  • The storage follow-up de5b185ba reproduced both review findings before the fix. Its 19 focused cases then passed; the broader local gate passed 1,436 cases with 0 failures/errors and 5 opt-in skips, plus sync/app lint, sync CRAP, adapter CRAP and dependency checks.

  • Complete remote Android CI passed on 16bc12f1e (run 41099337431): all 11 validation jobs succeeded, including the previously failing card-color case; the instrumentation inventory records 1,658 passed and 43 skipped.

  • The lyric-observer follow-up 6f2df24b3 reproduced the subscription failure before the fix. All 30 focused cases passed; the wider gate passed 1,406 cases with 0 failures/errors and 5 existing opt-in skips (1,052 sync, 333 adapter and 21 playback lyric cases). Sync lint, CRAP, dependency checks and adapter CRAP passed. Coverage includes strict initial read failures, recovery on the same subscription, failed-commit silence, successful retries, duplicate suppression and exception/cancellation propagation.

  • The test-only sheet-dismissal follow-up e3495f15c addresses the 3,000 ms dismissal timeout in CI on 6f2df24b3 (run 41109387596). It advances the frozen virtual clock through the hide animation before the original 3,000 ms wall-clock removal wait, retaining all gesture, geometry and callback assertions. Three independent API 35 ARM64 runs passed 27 cases with no skips (swangle, 1080x2400, 420 dpi); test APK assembly and app lint passed. The unmodified target passed once locally, so the slow CI timing was not reproduced locally. Complete remote Android CI passed on e3495f15c2fc3da6639c7470c9372d1ed3b2c7ed (run 41119700108): all 11 required jobs succeeded; the Telegram upload was skipped by its branch condition. The inventory verifier confirmed complete, disjoint instrumentation routes with 1,663 passed and 41 skipped cases. Both historical failures actually executed successfully: all 9 MoreOptionsSheetPredictiveBackTest cases and all 16 MusicServerSettingsLayoutTest cases passed without skips.

Secure-storage outages were simulated in JVM/Robolectric tests. A real TEE outage was not reproduced on the physical device.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T18:46:26.165561Z 6f2df24 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70fd6da130

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

override fun getAll(): Map<String, *> = access { it.all }
override fun getString(key: String?, defValue: String?): String? = access { it.getString(key, defValue) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle unavailable storage in the backup settings loader

When the Keystore is unavailable, these reads now throw instead of using the previous in-memory fallback, but SettingsBackupRestoreSection.loadBackupSyncPreferenceSnapshot() still calls getLegacyPlayHistoryUpdateModeName() and isDataSaverMode() without handling SyncStorageUnavailableException. Its caller is an unguarded LaunchedEffect, so opening the backup/sync settings during a storage outage produces an uncaught exception and crashes the app, rather than allowing the user to reach the newly added credential-recovery UI. Handle the unavailable-storage result in this loader as well, without treating it as an empty saved configuration.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 70fd6da and 3146538.

Files selected for processing: 1
  • modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/GlobalDownloadManagerDirectoryRefreshTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.

Walkthrough

新增可恢复的同步安全存储,并让 GitHub 和 WebDAV 同步配置在存储不可用时报告专用错误。配置写入可要求持久化成功;配置操作使用代次检查和任务取消处理并发更新。启动调度、设置对话框、协议升级、收藏同步及配置导出也增加了存储异常处理。新增测试覆盖存储恢复、配置操作顺序和相关界面状态。

Merge Risk: Moderate · previous review 31465

Credential recovery still has redundant session invalidation, and WebDAV saves can report success before credentials are durably written. These risks should be resolved before merge.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed 标题准确概括了在安全存储故障后恢复同步凭据这一主要变更,且简洁明确。
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 34 functions across 40 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

Minor · WebDAV 凭据保存未确认落盘,但界面会报告成功 · WebDavStorage.kt:49-54

modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/webdav/WebDavStorage.kt:49-54
Minor · Data Integrity & Integration · Quick win

WebDAV 凭据保存未确认落盘,但界面会报告成功

本 PR 让 GitHub 的 saveToken/saveRepository 使用 requirePersistence = true。WebDAV 的 saveConfiguration 仍然调用默认的 editTarget,并通过 apply() 异步写盘。

WebDavSyncViewModel.validateAndSaveConfiguration 在 saveConfiguration 返回后会设置 isConfigured = true 并显示 webdav_validate_success。如果磁盘写入失败,apply() 不会抛出异常。界面仍显示成功,但应用重启后服务器地址和密码会丢失。

WebDavSyncCredentialRecoveryTest 中模拟 saveConfiguration 抛出 IOException 的路径在生产代码中无法发生。请与 GitHub 保持一致,并要求提交成功。

🐛 建议修复
-        completionTime.editTarget(::storedTarget, target(serverUrl, basePath, username), TARGET_CHECKPOINT_KEYS) {
+        completionTime.editTarget(::storedTarget, target(serverUrl, basePath, username), TARGET_CHECKPOINT_KEYS,
+            requirePersistence = true) {
Other comments: 1
modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/secure/RecoverableSyncPreferences.kt: 1

93-97: Minor · Data Integrity & Integration · Quick win

拒绝旧编辑器时不应使当前健康会话失效

use 在 access { } 内部抛出 SyncStorageUnavailableException。access 的 catch 会把任何异常当作存储故障处理。它会执行 session++、注销监听器并清空 current。下一次访问会重新打开存储。由于 openedBefore == true,invalidateSessions 会再次递增 KEY_SYNC_MUTATION_VERSION 和 KEY_CONFIGURATION_GENERATION。

触发条件:后台线程在故障前取得编辑器,恢复后再调用 putX/commit。结果是已恢复的健康会话被再次作废。正在进行的同步会失去 captureSyncMetadataGuard 的有效性,完成时间无法写入。*IfMutationVersion 写入也会因版本变化而失败。旧编辑器每调用一次 stage,这个过程就会重复一次。

旧编辑器检查只表示调用方使用了过期句柄。底层存储并没有发生故障。请在 access 外部完成会话比较,或者在 catch 中跳过这类拒绝,不重置 current。

🐛 建议修复
+    private class StaleEditorException : Exception()
+
     private fun <T> access(action: (SharedPreferences) -> T): T = synchronized(lock) {
         try {
             action(persistent())
         } catch (error: CancellationException) {
             throw error
+        } catch (_: StaleEditorException) {
+            throw SyncStorageUnavailableException()
         } catch (error: Exception) {
@@
         private fun <T> use(action: () -> T): T = access { active ->
             // 重开前创建的编辑器不能在新会话中提交旧快照
-            if (active !== owner || capturedSession != session) throw SyncStorageUnavailableException()
+            if (active !== owner || capturedSession != session) throw StaleEditorException()
             action()
         }

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 44d00ec and 70fd6da.

Files selected for processing: 42
  • app/src/main/java/moe/ouom/neriplayer/core/startup/sync/StartupSyncScheduler.kt
  • app/src/main/java/moe/ouom/neriplayer/core/startup/sync/StartupSyncWarningCoordinator.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsGitHubDialogs.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavDialogs.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/sync/upgrade/SyncProtocolUpgradeEntry.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/sync/upgrade/SyncProtocolUpgradeViewModel.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/GitHubSyncViewModel.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncViewModel.kt
  • app/src/test/java/moe/ouom/neriplayer/core/startup/sync/StartupSyncSchedulerFlowTest.kt
  • app/src/test/java/moe/ouom/neriplayer/core/startup/sync/StartupSyncWarningCoordinatorTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsGitHubCredentialDialogTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/screen/tab/settings/dialog/SettingsWebDavCredentialStorageTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/sync/upgrade/SyncProtocolStartupStorageFailureTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/sync/upgrade/SyncProtocolUpgradeFailureTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/GitHubSyncCredentialRecoveryTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/SyncSettingsActionsTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/SyncViewModelCancellationTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/SyncViewModelCompletionTimeTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/SyncViewModelTargetBindingTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncConfigurationOrderingTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/WebDavSyncCredentialRecoveryTest.kt
  • modules/common/src/main/res/values-en/strings_sync_backup.xml
  • modules/common/src/main/res/values-zh/strings_sync_backup.xml
  • modules/common/src/main/res/values/strings_sync_backup.xml
  • modules/local/src/main/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepository.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/github/GitHubSyncBackend.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/SyncProtocolUpgradeRepository.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/config/ConfigFileManagerExportTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/playlist/favorite/FavoritePlaylistRepositoryEditingTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/sync/host/SyncBackendTransportTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/sync/host/SyncProtocolUpgradeRepositoryTest.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/github/SecureTokenStorage.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/secure/EncryptedSyncPreferences.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/secure/RecoverableSyncPreferences.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/secure/SyncPreferenceRecovery.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/state/GitHubSyncConfigurationStore.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/state/SyncCompletionTimeStore.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/state/SyncStoreKeys.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/store/webdav/WebDavStorage.kt
  • modules/sync/src/test/java/moe/ouom/neriplayer/data/sync/store/secure/RecoverableSyncPreferencesTest.kt
  • modules/sync/src/test/java/moe/ouom/neriplayer/data/sync/store/secure/SyncPreferenceRecoveryTest.kt
  • modules/sync/src/test/java/moe/ouom/neriplayer/data/sync/store/state/SyncConfigurationStorageTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31465383f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

override fun getAll(): Map<String, *> = access { it.all }
override fun getString(key: String?, defValue: String?): String? = access { it.getString(key, defValue) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle storage outages in playback-history mutations

When the Keystore is unavailable, playing a non-local song now causes PlayHistoryRepository.record() to throw from storage.removeRecentPlayDeletion(song.identityKey()) after saving the history entry. updateRememberedPlaybackPosition() has the same unguarded call. Both run in scope.launch under a SupervisorJob without an exception handler, so the new throwing reads produce an uncaught exception and crash the app—even when cloud sync is not configured. Handle SyncStorageUnavailableException at these playback-history boundaries while preserving the saved local history and existing durable deletion state.

Useful? React with 👍 / 👎.

override fun onFailure(error: Throwable) {
if (error is TokenExpiredException) storage.clearToken()
}
override fun onFailure(error: Throwable) = Unit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pause background GitHub sync after authentication failures

When an automatic GitHub sync receives a 401, removing the backend's token clearing leaves both isConfigured() and isAutoSyncEnabled() true. The replacement pause logic exists only in GitHubSyncViewModel.authenticationFailed(), which background workers never invoke: createGitHubWorkerHost() delegates to SyncRepositoryWorkerHost.handleFailure(), which only reports the failure. Consequently, subsequent favorite/history changes continue scheduling requests with the expired token, and SyncWorkerFailurePolicy.github() requests a notification for every authentication failure, even with silent failures enabled. Preserve the credentials, but add a configuration-guarded pause to the background authentication-failure path as well.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de5b185ba1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +227 to +228
val lyricOverridesVersion: StateFlow<Long>
get() = lyricOverrides.version

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep lyric-override observation recoverable during storage outages

If secure storage is unavailable when the player initializes, accessing this property initializes SyncLyricOverrideStore, whose notificationMarker() reads the unavailable preferences and throws. In PlayerManagerLifecycleExtensions.observeCommittedLyricOverrides(), this happens before collection starts, so the outer catch logs the error and permanently exits the observer. Recovering credentials later does not restart that observer, meaning subsequently synchronized lyric overrides no longer update the current song or playback queue until the player is restarted. Keep the notification flow obtainable without reading encrypted state, or retry the subscription after storage recovery.

Useful? React with 👍 / 👎.

@cwuom
cwuom merged commit 97e0b66 into master Oct 31, 2026
12 checks passed
@cwuom
cwuom deleted the fix/sync-credential-recovery branch October 31, 2026 00:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant