Skip to content

feat(secure): migrate TokenStore to KSafe (Phase 1)#632

Merged
rainxchzed merged 8 commits into
mainfrom
feat/ksafe-migration
May 18, 2026
Merged

feat(secure): migrate TokenStore to KSafe (Phase 1)#632
rainxchzed merged 8 commits into
mainfrom
feat/ksafe-migration

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 18, 2026

Summary

First phase of DataStore → KSafe migration. KSafe (https://github.com/ioannisa/KSafe) gives us AES-256-GCM encryption by default, hardware-backed Keystore on Android, single-instance KMP API across Android + JVM.

This PR: most security-sensitive store first — GitHub OAuth tokens.

Architecture

  • gradle/libs.versions.tomlksafe = "2.0.0" + library entry
  • core/data/build.gradle.kts — added implementation(libs.ksafe)
  • core/data/src/{android,jvm}Main/.../di/PlatformModule.{android,jvm}.kt — register KSafe(fileName = "ghs-tokens") singleton under qualifier "tokens"
  • DefaultTokenStore:
    • Was: DataStore<Preferences> + manual JSON encode/decode of GithubDeviceTokenSuccessDto in plaintext string pref
    • Now: KSafe + legacyDataStore for one-shot migration. Token stored as @Serializable object directly (KSafe handles JSON + encryption transparently)
    • Auto-migration on first read: if KSafe vault empty + legacy DataStore has token, decode, write to KSafe, clear legacy
    • Existing users keep their session — no re-auth required
  • Backward compat: legacyDataStore injected so migration shim has access; can drop the legacy dep after a couple of releases
  • Whatsnew bullet × 13 locales

Out of scope (follow-up PRs)

  • TweaksRepositoryImpl — biggest consumer (~35 prefs); Plain mode for cosmetic prefs is fine
  • ProxyRepositoryImpl — proxy credentials (encrypted)
  • MirrorRepositoryImpl / MirrorPersistence
  • AnnouncementsCacheStoreImpl
  • DeviceIdentityRepositoryImpl
  • ExternalImportRepositoryImpl
  • SlowDownloadDetectorImpl

Each consumer migrates in its own PR with the same auto-migration shim pattern this PR establishes.

Test plan

  • Existing install with a saved GitHub token: launch new build → token auto-migrates to encrypted KSafe vault → no logout, no re-auth
  • Sign out → KSafe vault cleared + legacy DataStore also wiped
  • Fresh install: sign in → token written only to KSafe vault, never to legacy DataStore
  • Inspect on-disk file ghs-tokens.preferences_pb — token bytes are ciphertext, not JSON
  • JVM + Android compile clean

Summary by CodeRabbit

  • New Features
    • All stored preferences and credentials (GitHub tokens, proxy settings, device ID, mirror choices, announcements cache, app settings) are now encrypted at rest with AES‑256‑GCM via the platform keystore.
    • Existing stored data is migrated seamlessly to the new encrypted store on first launch.
    • GitHub sign‑in simplified to the standard web/browser flow — no 8‑digit code required.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

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

Walkthrough

Adds KSafe (catalog + dependency), registers named KSafe instances for tokens/prefs/announcements, implements a typed DataStore→KSafe migrator, and migrates multiple repositories (tokens, tweaks, proxy, mirror, announcements, device id, external import, slow-download) to KSafe with guarded one-time migrations; release notes and project version updated.

Changes

KSafe migration and secure persistence

Layer / File(s) Summary
KSafe library integration
gradle/libs.versions.toml, core/data/build.gradle.kts
Add ksafe version and library entry to the version catalog and include implementation(libs.ksafe) in core/data commonMain.
Platform-specific KSafe registration
core/data/src/androidMain/.../PlatformModule.android.kt, core/data/src/jvmMain/.../PlatformModule.jvm.kt
Register named KSafe singletons ("tokens", "prefs", "announcements_cache") configured with fileName values (ghs_tokens, ghs_prefs, ghs_announcements) for platform DI.
DI wiring: provide KSafe + legacy DataStore
core/data/src/commonMain/.../SharedModule.kt
Update providers to inject qualified ksafe and legacyDataStore into repositories and TokenStore constructors instead of prior unqualified DataStore/preference injection.
DataStore → KSafe migrator
core/data/src/commonMain/kotlin/.../DataStoreToKSafeMigrator.kt
Add migrateDataStoreToKSafe and MigrationEntry to perform idempotent, typed copies from a DataStore snapshot into KSafe and set a migration marker only on full success.
DefaultTokenStore refactor + migration
core/data/src/commonMain/.../DefaultTokenStore.kt, core/data/src/commonMain/.../SharedModule.kt
Refactor DefaultTokenStore to persist tokens in KSafe, add migrateIfNeeded() with mutex/async gate to copy/deserialize legacy token and remove the old key; wire ksafe (tokens) + legacy DataStore in DI.
TweaksRepository migration
core/data/src/commonMain/.../repository/TweaksRepositoryImpl.kt
Rewrite tweaks storage to use ksafe.getFlow / ksafe.put with a migrationScope using migrateDataStoreToKSafe and mapped legacyEntries() for bulk migration; add gated flows and rmw locks.
ProxyRepository migration
core/data/src/commonMain/.../repository/ProxyRepositoryImpl.kt
Refactor proxy persistence to KSafe with scoped keys, guarded migrateIfNeeded() to transform legacy preferences into scoped KSafe keys, and update parse/set logic to use KSafe flows and put/delete.
MirrorRepository & SlowDownload changes
core/data/src/commonMain/.../mirror/MirrorRepositoryImpl.kt, core/data/src/commonMain/.../download/SlowDownloadDetectorImpl.kt
Migrate mirror catalog, preferences, and auto-suggest state to KSafe; run migrateDataStoreToKSafe at init; use ksafe.getFlow + combine for reactive preferences; SlowDownloadDetector now reads dismiss/snooze from KSafe.
Announcements / Device / ExternalImport migrations
core/data/src/commonMain/.../repository/*
Migrate announcements cache, device id, initial-scan flag, and external-import initial scan flag to KSafe with per-repository migrateIfNeeded logic and update flows/writes to use KSafe.
Multilingual release notes & version bump
core/presentation/src/commonMain/composeResources/files/whatsnew/*/18.json, gradle/libs.versions.toml
Add or expand WhatsNew bullets across languages to state KSafe-encrypted persistence (AES-256-GCM) and bump projectVersionName/projectVersionCode.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through code and keys tonight,
moved tokens where they slumber tight,
migrated old crumbs with cautious care,
tucked prefs in KSafe — secure and fair,
now secrets sleep and I feel light.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat(secure): migrate TokenStore to KSafe (Phase 1)' clearly and specifically describes the main change: migrating TokenStore to use KSafe encryption.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ksafe-migration

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 and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 18, 2026

Greptile Summary

This PR migrates all major persistent stores from plaintext DataStore<Preferences> to KSafe (AES-256-GCM, hardware-backed Keystore on Android), introducing a shared DataStoreToKSafeMigrator helper and per-repository one-shot migration shims that preserve existing user data without forced re-auth. The scope is broader than the Phase 1 description suggests — every consumer listed as "out of scope" is also migrated here.

  • Token store: DefaultTokenStore now uses ksafe.getFlow() for a live, gated flow; migration correctly conditions legacy deletion on a successful KSafe write.
  • Shared migrator: DataStoreToKSafeMigrator provides per-entry success tracking and only sets the completion marker when all entries and the legacy delete both succeed.
  • Proxy store: ProxyRepositoryImpl migrates with per-field success tracking but loses DataStore's atomic edit {} guarantee in setProxyConfig() — a process kill mid-write can leave a partial config that silently falls back to ProxyConfig.System.

Confidence Score: 3/5

The migration logic across most stores is careful and correct, but the proxy write path lost the atomicity guarantee that DataStore provided — a mid-write crash corrupts the stored config silently.

The setProxyConfig() rewrite issues up to five sequential independent KSafe puts where the previous DataStore edit {} block was a single atomic transaction. A process kill between the type write and the host/port writes leaves type = "http" with no host, which parseConfig silently demotes to ProxyConfig.System. Users with HTTP or SOCKS proxies who encounter this window lose their configuration without any visible indication. Combined with the multiple pre-existing issues already flagged in earlier review threads, the proxy subsystem warrants a second pass before shipping.

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt — specifically the setProxyConfig() method and its sequential per-field writes.

Important Files Changed

Filename Overview
core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt Token storage migrated to KSafe with correct migration guard: KSafe write must succeed before legacy entry is removed, tokenFlow() correctly uses a live ksafe.getFlow() via emitAll, and clear() cleans both stores.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt Shared migrator helper: per-entry success tracking, partial-delete safety, and marker-only-on-full-success — solid foundation, though the retry-overwrites-user-changes risk on a partial prior run remains (flagged in existing thread).
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt Proxy store migrated to KSafe with correct per-field success tracking in migrateIfNeeded(); however setProxyConfig() now issues up to 5 non-atomic sequential KSafe writes, regressing from DataStore's atomic edit {} — a crash mid-write leaves a partial proxy config that silently falls back to System.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt Mirror repo migrated to KSafe using the shared migrateDataStoreToKSafe() helper; correctly gates observePreference() and write operations on migrationDeferred, and uses appScope rather than a private leaked scope.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt 35-key migration delegated to the shared helper with correct Set<String>→List<String> representation that matches all read sites; all write paths gate on migrationDeferred.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt Migration correctly conditions legacy delete on a successful KSafe write; mutex around getDeviceId prevents concurrent mint-and-race; migrated flag used for in-session deduplication only.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt Migration correctly guards legacy deletion behind a successful KSafe write; migrationDeferred gates all reads and writes cleanly.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt Correctly reads MIRROR_MIGRATION_MARKER before acting on dismiss/snooze state, addressing the previously-flagged race where defaults would shadow migrated values; shared KSafe instance ensures the marker written by MirrorRepositoryImpl is visible here.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt Initial-scan flag migration now correctly conditions legacy delete on successful KSafe write; no persistent migrated flag means the check re-runs on each scheduleInitialScanIfNeeded() call, but the early-exit on non-null KSafe value keeps it cheap.
core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt Three named KSafe singletons registered (tokens, prefs, announcements_cache) with distinct file names, correctly separating security domains.
core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt JVM counterpart of the KSafe singleton registrations; no context parameter (desktop), symmetric with Android module.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App Launch / Singleton Init] --> B{KSafe marker set?}
    B -- Yes --> C[Skip migration\nRead from KSafe directly]
    B -- No --> D[Read legacy DataStore snapshot]
    D --> E{Snapshot readable?}
    E -- No --> F[Abort — retry next launch\nmarker NOT set]
    E -- Yes --> G[Copy entries to KSafe\nper-entry success tracking]
    G --> H{Any write failure?}
    H -- Yes --> I[Delete only successfully\ncopied legacy keys\nmarker NOT set]
    H -- No --> J[Delete all legacy keys]
    J --> K{Delete succeeded?}
    K -- No --> I
    K -- Yes --> L[Set migration marker in KSafe]
    L --> C
    I --> M[Retry on next launch]

    style F fill:#f9f,stroke:#c33
    style I fill:#f9f,stroke:#c33
    style M fill:#f9f,stroke:#c33
    style L fill:#bfb,stroke:#393
    style C fill:#bfb,stroke:#393
Loading

Fix All in Claude Code

Reviews (6): Last reviewed commit: "chore: drop committed .DS_Store + ignore..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt`:
- Around line 186-190: The KSafe instance created in the single qualifier
named("tokens") uses fileName "ghs-tokens" which violates KSafe 2.0.0's filename
regexp; change the fileName in the KSafe constructor to use an underscore (e.g.,
"ghs_tokens") and update the same occurrence in the JVM platform module so both
KSafe constructors (the one in PlatformModule.android.kt and its JVM
counterpart) use a valid name matching [a-z][a-z0-9_]*.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt`:
- Around line 35-45: The save() and clear() paths run concurrently with the
background migrateIfNeeded() (launched from migrationScope) and bypass the
migrationLock, allowing races where legacy tokens overwrite or are left behind;
update save() and clear() to acquire the same migrationLock used by
migrateIfNeeded() (e.g., call migrationLock.withLock { ... } or otherwise
suspend until migration completes) around reads/writes (references:
migrationScope, migrateIfNeeded(), migrationLock, save(), clear(), ksafe.put,
tokenKey) so that normal writes are serialized with the migration gate.
- Around line 90-102: The migration currently removes the legacy key and sets
migrated = true even when
json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), legacyRaw) or
ksafe.put(tokenKey, parsed) fail; change the flow so you only remove
legacyDataStore.edit { it.remove(legacyKey) } and set migrated = true after a
successful parse and a successful ksafe.put; use runCatching around decode and
around ksafe.put and only proceed to edit/remove and set migrated when both
runCatching results are successful (i.e., parsed != null and the ksafe.put
runCatching completed without exception), leaving the legacy entry intact and
migrated false if either step fails so a retry can occur on next read.
- Around line 47-54: Replace the one-shot callbackFlow in
DefaultTokenStore.tokenFlow() with KSafe's reactive API: use
ksafe.getFlow(tokenKey, defaultValue = null, encrypted = true?) or
ksafe.getStateFlow(tokenKey, defaultValue = null, encrypted = true, scope =
coroutineScope) so the Flow emits on subsequent save() and clear() calls; update
tokenFlow() to return that Flow/StateFlow directly and ensure you pass the same
tokenKey and encryption flag used by save()/clear() and provide a scope if using
getStateFlow().

In `@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt`:
- Around line 99-100: The KSafe registration in PlatformModule.jvm.kt uses an
invalid fileName "ghs-tokens" for eu.anifantakis.lib.ksafe.KSafe; change the
fileName to match KSafe 2.0.0's required pattern ([a-z][a-z0-9_]*), e.g. use
"ghs_tokens" or "ghstokens", by updating the KSafe(...) invocation so the
fileName argument conforms to the allowed characters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: baa96649-4462-4d79-b35e-0eaa16edc0db

📥 Commits

Reviewing files that changed from the base of the PR and between 6632fd3 and 1d41669.

📒 Files selected for processing (19)
  • core/data/build.gradle.kts
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json
  • gradle/libs.versions.toml

Comment thread core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt Outdated
…, Mirror, Announcements, DeviceIdentity, ExternalImport, SlowDownload)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (1)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt (1)

9-20: ⚡ Quick win

Remove the new KDoc block to match repo convention.

This file adds a top-level KDoc block that isn’t required for a non-obvious invariant/workaround case.

As per coding guidelines "Do not add KDoc or inline comments unless explicitly requested; only add inline comments for non-obvious invariants, tricky concurrency, or workarounds".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt`
around lines 9 - 20, Remove the top-level KDoc block added to
DataStoreToKSafeMigrator.kt so the file follows repo convention; specifically
delete the multi-line KDoc comment at the top of the file that describes the
one-shot copier behavior (the block above the DataStoreToKSafeMigrator
implementation), leaving only necessary code and preserving any non-obvious
inline comments inside functions like migrate or related methods if present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt`:
- Around line 52-58: The legacy cache is being cleared unconditionally even if
the ksafe.put(K_CACHED_PAYLOAD, legacy) write fails; change the flow so
legacyDataStore.edit {
remove(stringPreferencesKey("announcements_cached_payload")) } only runs after
runCatching { ksafe.put(K_CACHED_PAYLOAD, legacy) } succeeds — i.e., check the
result (or use onSuccess) of ksafe.put before calling legacyDataStore.edit, and
keep the existing logger.w on failure for the legacy clear branch; reference the
ksafe.put(K_CACHED_PAYLOAD, legacy) call and
legacyDataStore.edit/remove(stringPreferencesKey("announcements_cached_payload"))
to locate the code to adjust.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt`:
- Around line 36-40: resetDeviceId currently only writes the new DEVICE_ID_KEY
via ksafe.put and can leave the legacy "anonymous_device_id" key behind if reset
happens before getDeviceId; update resetDeviceId (inside the
deviceIdMutex.withLock block) to also remove the legacy key (e.g., call
ksafe.remove("anonymous_device_id") or equivalent) so the old plaintext ID is
cleared whenever a new device id is generated, ensuring both the new
DEVICE_ID_KEY is written and the legacy anonymous_device_id is deleted
atomically under the same mutex.
- Around line 54-57: The legacy deletion is happening unconditionally; change
the logic so that the legacyDataStore.edit call that removes
stringPreferencesKey("anonymous_device_id") runs only if the secure write via
ksafe.put(DEVICE_ID_KEY, legacy) succeeds; specifically, check the result of
runCatching { ksafe.put(DEVICE_ID_KEY, legacy) } (or use onSuccess/onFailure)
and invoke legacyDataStore.edit {
it.remove(stringPreferencesKey("anonymous_device_id")) } only in the success
branch to avoid losing the prior stable ID when KSafe write fails.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt`:
- Around line 485-488: The code currently calls runCatching {
ksafe.put(K_INITIAL_SCAN_AT, legacyValue) } and then unconditionally clears the
legacy flag via legacyDataStore.edit {
remove(longPreferencesKey("external_import_initial_scan_at")) }, which can drop
the migration source if the KSafe write failed; update
ExternalImportRepositoryImpl so the legacy preference is removed only when the
KSafe write succeeds — e.g., capture the result of runCatching for
ksafe.put(K_INITIAL_SCAN_AT, legacyValue) and in its onSuccess block call
legacyDataStore.edit {
it.remove(longPreferencesKey("external_import_initial_scan_at")) } (or check
isSuccess before calling legacyDataStore.edit) to ensure removal happens only
after successful ksafe.put.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt`:
- Around line 178-199: The migration currently ignores failures from each
ksafe.put call and then unconditionally clears legacyDataStore keys and sets
MIGRATION_MARKER, which can destroy data on partial failures; update the
migration in ProxyRepositoryImpl so you collect the results/errors from each
ksafe.put (do not swallow them with runCatching without checking), only proceed
to legacyDataStore.edit(...) and runCatching { ksafe.put(MIGRATION_MARKER, true)
} after all required ksafe.puts have succeeded, and if any put failed abort the
cleanup (propagate or log the error and leave legacy keys intact) so
credentials/config are not lost.
- Around line 35-37: The background migrator started in init
(migrationScope.launch { migrateIfNeeded() }) can finish after KSafe is accessed
by getProxyConfig() and setProxyConfig(), so gate all KSafe accesses until
migration completes: add a migration completion synchronization primitive (e.g.,
a CompletableDeferred or a Mutex+Boolean) that migrateIfNeeded() completes/sets
when done, then make getProxyConfig(), setProxyConfig(), and any other methods
that touch KSafe await that primitive before proceeding; update references in
ProxyRepositoryImpl (notably migrateIfNeeded, getProxyConfig, setProxyConfig and
the blocks around lines ~62-73, ~108-137, ~143-146) to call
ensureMigrationFinished()/awaitMigration() so reads/writes happen only after
migration finishes.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt`:
- Around line 220-253: Concurrent read-modify-write on preferences in
setLastSeenWhatsNewVersionCode, addAnnouncementDismissedId,
addAnnouncementAcknowledgedId, and setAnnouncementCategoryMuted can lose
updates; make these operations atomic by serializing them (either with ksafe's
atomic update API if available or with a repository-level Mutex). Wrap the
current get/put sequence for K_LAST_SEEN_WHATS_NEW_WHATS_NEW_VERSION_CODE,
K_ANNOUNCEMENTS_DISMISSED_IDS, K_ANNOUNCEMENTS_ACKNOWLEDGED_IDS, and
K_ANNOUNCEMENTS_MUTED_CATEGORIES in a single critical section (e.g., declare a
private val prefsMutex = Mutex() and use prefsMutex.withLock { val current =
ksafe.get(...); compute updated; ksafe.put(...) }) so reads and writes cannot
interleave and overwrite each other. Ensure all four methods use the same
mutex/atomic update to guarantee serialization.
- Around line 33-44: The init currently launches migrateDataStoreToKSafe in
migrationScope (migrationScope, migrateDataStoreToKSafe, legacyDataStore, ksafe,
MIGRATION_MARKER, legacyEntries) as a fire-and-forget task which allows
consumers to observe KSafe defaults before migration finishes; fix by blocking
reads until migration completes: either run the migration synchronously in init
(e.g., perform the migrateDataStoreToKSafe call on the calling coroutine/thread
instead of launching) or add a migrationReady signal (CompletableDeferred or
Deferred returned from migrationScope.launch/async) that all public read/write
entry points await before accessing ksafe, and set/complete that signal when
migrateDataStoreToKSafe returns/throws so no KSafe defaults are visible
pre-migration.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt`:
- Around line 30-33: The migrator currently writes ksafe.put(markerKey, true)
even when legacy.data.first() or subsequent copy operations fail; change the
logic so that markerKey is only set after the entire migration succeeds.
Specifically, in DataStoreToKSafeMigrator, stop calling ksafe.put(markerKey,
true) inside the failure branches around legacy.data.first() or
partial-runCatching blocks and instead perform the ksafe.put(markerKey, true)
only after the successful read-and-copy sequence completes (i.e., after the code
that iterates/copies the snapshot and returns success). Update all occurrences
mentioned (the runCatching around legacy.data.first(), the blocks at lines
~37-42 and ~52-53) to check runCatching.isSuccess or handle exceptions and only
write the marker on success.

---

Nitpick comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt`:
- Around line 9-20: Remove the top-level KDoc block added to
DataStoreToKSafeMigrator.kt so the file follows repo convention; specifically
delete the multi-line KDoc comment at the top of the file that describes the
one-shot copier behavior (the block above the DataStoreToKSafeMigrator
implementation), leaving only necessary code and preserving any non-obvious
inline comments inside functions like migrate or related methods if present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: df09ed6c-8f52-4f08-9798-62f682eecfed

📥 Commits

Reviewing files that changed from the base of the PR and between 1d41669 and b381dc6.

📒 Files selected for processing (25)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorPersistence.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json
💤 Files with no reviewable changes (1)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorPersistence.kt
✅ Files skipped from review due to trivial changes (9)
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 18, 2026

Want your agent to iterate on Greptile's feedback? Try greploops.

… legacy-clear, atomic RMW, never mark complete on failure
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt (1)

42-59: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep retrying the DataStore→KSafe migration until it actually completes.

This deferred is completed after the first attempt regardless of migrateDataStoreToKSafe() outcome. That defeats the helper’s retry contract: after one transient failure, every getter/setter permanently falls back to KSafe defaults instead of retrying the legacy copy on the next access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt`
around lines 42 - 59, The init block currently completes migrationDeferred
unconditionally after the first migrateDataStoreToKSafe() attempt, which
prevents retries on transient failures; change the logic so
migrationDeferred.complete(Unit) is only called when
migrateDataStoreToKSafe(...) succeeds (e.g., check runCatching result or use
try/catch), and on failure do not complete the deferred so gatedGetFlow (and
other awaiters) will keep waiting and cause subsequent accesses to re-run or
retry the migration; locate the init coroutine and the migrateDataStoreToKSafe
invocation plus migrationDeferred to implement this flow control (or add a retry
loop with backoff that completes migrationDeferred only on successful
migration).
core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt (1)

37-41: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't open the token store after an unsuccessful migration attempt.

migrationDeferred is completed unconditionally, but migrateIfNeeded() has several early-return failure paths. That means a transient legacy read/parse/write/cleanup failure permanently switches all later reads to KSafe-only, which can sign existing users out or leave the plaintext legacy token behind forever with no retry path.

Also applies to: 81-106

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt`
around lines 37 - 41, The init migration currently completes migrationDeferred
unconditionally even if migrateIfNeeded() failed or returned early; change the
logic so migrationDeferred.complete(Unit) is only called when migrateIfNeeded()
actually succeeds. Replace the unconditional runCatching { migrateIfNeeded() };
migrationDeferred.complete(Unit) with a guarded flow that runs migrateIfNeeded()
and on success calls migrationDeferred.complete(Unit), and on failure
logs/handles the error (or completes the deferred exceptionally) but does NOT
complete it normally so consumers don't treat an unsuccessful migration as done;
apply the same guarded change to the other migration block (the
migrationScope.launch usage around migrateIfNeeded() at lines ~81-106)
referencing DefaultTokenStore, migrationScope.launch, migrateIfNeeded(), and
migrationDeferred to locate the spots.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt (1)

50-70: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't mark migration done while the plaintext legacy ID may still exist.

If KSafe already has a value, or if the legacyDataStore.edit cleanup fails, this method still flips migrated = true. After that, the old anonymous_device_id can stay on disk permanently after a failed cleanup or reset path.

Suggested fix
 private suspend fun migrateIfNeeded() {
     if (migrated) return
-    val existing = runCatching { ksafe.get(DEVICE_ID_KEY, "") }.getOrDefault("")
-    if (existing.isNotBlank()) {
-        migrated = true
-        return
-    }
+    val existing = runCatching { ksafe.get(DEVICE_ID_KEY, "") }.getOrElse { return }
         val legacy = runCatching {
             legacyDataStore.data.first()[stringPreferencesKey("anonymous_device_id")]
         }.getOrNull()
+    if (existing.isNotBlank() && legacy.isNullOrBlank()) {
+        migrated = true
+        return
+    }
     if (!legacy.isNullOrBlank()) {
         val putResult = runCatching { ksafe.put(DEVICE_ID_KEY, legacy) }
         if (putResult.isFailure) {
             // Keep legacy intact for next attempt.
             return
         }
-        runCatching {
+        val deleteResult = runCatching {
             legacyDataStore.edit { it.remove(stringPreferencesKey("anonymous_device_id")) }
         }
+        if (deleteResult.isFailure) return
     }
     migrated = true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt`
around lines 50 - 70, The migration currently sets migrated = true even when
legacy cleanup fails, which can leave the plaintext anonymous_device_id on disk;
update migrateIfNeeded so migrated is only set to true when either an existing
KSafe value is present or when the legacy value was successfully moved AND the
legacyDataStore.edit removal succeeded: inside migrateIfNeeded, keep the
early-return for existing KSafe values, but for the legacy branch, only set
migrated = true after runCatching { ksafe.put(DEVICE_ID_KEY, legacy) } is
successful AND the subsequent runCatching { legacyDataStore.edit {
it.remove(stringPreferencesKey("anonymous_device_id")) } } also succeeds; if
either operation fails, return without flipping migrated so the cleanup can be
retried later.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt`:
- Around line 67-70: DefaultTokenStore.clear() currently swallows failures from
ksafe.delete(tokenKey) by wrapping it in runCatching; change it so deletion
failures are not ignored: after migrationDeferred.await() call
ksafe.delete(tokenKey) without runCatching so any exception propagates (or if
you want to attempt legacy cleanup too, catch the ksafe exception into a
variable, still call legacyDataStore.edit { it.remove(legacyKey) } (you may keep
that in runCatching), and then rethrow the original ksafe exception), ensuring
exceptions from ksafe.delete(tokenKey) surface to callers of
DefaultTokenStore.clear().

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt`:
- Around line 25-33: The getDeviceId() implementation currently treats any
ksafe.get failure as "missing" and may generate a new ID; change it so a read
error is not turned into a new ID: inside deviceIdMutex.withLock call, call
migrateIfNeeded(), then attempt ksafe.get(DEVICE_ID_KEY, "") but if that
operation fails propagate the exception (or retry) instead of using
getOrDefault(""); only when the read succeeds and returns blank should you
generate a new UUID (Uuid.random().toString()), persist it with
ksafe.put(DEVICE_ID_KEY, generated) and return it; in other words, replace the
runCatching(...).getOrDefault("") pattern with explicit success/failure handling
so failures from ksafe.get are not treated as missing.

---

Duplicate comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt`:
- Around line 37-41: The init migration currently completes migrationDeferred
unconditionally even if migrateIfNeeded() failed or returned early; change the
logic so migrationDeferred.complete(Unit) is only called when migrateIfNeeded()
actually succeeds. Replace the unconditional runCatching { migrateIfNeeded() };
migrationDeferred.complete(Unit) with a guarded flow that runs migrateIfNeeded()
and on success calls migrationDeferred.complete(Unit), and on failure
logs/handles the error (or completes the deferred exceptionally) but does NOT
complete it normally so consumers don't treat an unsuccessful migration as done;
apply the same guarded change to the other migration block (the
migrationScope.launch usage around migrateIfNeeded() at lines ~81-106)
referencing DefaultTokenStore, migrationScope.launch, migrateIfNeeded(), and
migrationDeferred to locate the spots.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt`:
- Around line 50-70: The migration currently sets migrated = true even when
legacy cleanup fails, which can leave the plaintext anonymous_device_id on disk;
update migrateIfNeeded so migrated is only set to true when either an existing
KSafe value is present or when the legacy value was successfully moved AND the
legacyDataStore.edit removal succeeded: inside migrateIfNeeded, keep the
early-return for existing KSafe values, but for the legacy branch, only set
migrated = true after runCatching { ksafe.put(DEVICE_ID_KEY, legacy) } is
successful AND the subsequent runCatching { legacyDataStore.edit {
it.remove(stringPreferencesKey("anonymous_device_id")) } } also succeeds; if
either operation fails, return without flipping migrated so the cleanup can be
retried later.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt`:
- Around line 42-59: The init block currently completes migrationDeferred
unconditionally after the first migrateDataStoreToKSafe() attempt, which
prevents retries on transient failures; change the logic so
migrationDeferred.complete(Unit) is only called when
migrateDataStoreToKSafe(...) succeeds (e.g., check runCatching result or use
try/catch), and on failure do not complete the deferred so gatedGetFlow (and
other awaiters) will keep waiting and cause subsequent accesses to re-run or
retry the migration; locate the init coroutine and the migrateDataStoreToKSafe
invocation plus migrationDeferred to implement this flow control (or add a retry
loop with backoff that completes migrationDeferred only on successful
migration).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bdfe48e9-9cbb-4394-a0f4-706fb2f7bf6f

📥 Commits

Reviewing files that changed from the base of the PR and between 8b710c0 and f0de469.

📒 Files selected for processing (9)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt
  • gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (5)
  • gradle/libs.versions.toml
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt

@rainxchzed rainxchzed force-pushed the feat/ksafe-migration branch from c8d93b4 to 81d2f6c Compare May 18, 2026 06:49
@rainxchzed rainxchzed merged commit 2c70263 into main May 18, 2026
1 check passed
@rainxchzed rainxchzed deleted the feat/ksafe-migration branch May 18, 2026 06:51
Comment on lines +130 to +151
ksafe.delete(keys.username); ksafe.delete(keys.password)
}
is ProxyConfig.Http -> {
ksafe.put(keys.type, "http")
ksafe.put(keys.host, config.host)
ksafe.put(keys.port, config.port)
writeOrClear(keys.username, config.username)
writeOrClear(keys.password, config.password)
}
is ProxyConfig.Socks -> {
ksafe.put(keys.type, "socks")
ksafe.put(keys.host, config.host)
ksafe.put(keys.port, config.port)
writeOrClear(keys.username, config.username)
writeOrClear(keys.password, config.password)
}
}
ProxyManager.setConfig(scope, config)
}

private suspend fun writeOrClear(key: String, value: String?) {
if (value != null) ksafe.put(key, value) else ksafe.delete(key)
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 Non-atomic proxy writes regress from DataStore's edit {} atomicity

The old preferences.edit { prefs -> ... } block was a single atomic DataStore transaction. The new code issues up to 5 independent ksafe.put() / ksafe.delete() calls sequentially. If the process is killed between ksafe.put(keys.type, "http") and the subsequent ksafe.put(keys.host, config.host), the vault ends up with type = "http" but no host. On the next launch, parseConfig sees validHost = null and silently falls back to ProxyConfig.System, losing the user's configured proxy without any visible error. The same partial-write window exists for all compound config types (Http, Socks). KSafe does not appear to expose a transactional / batch-write API, so consider writing a single serialized object (e.g. a small @Serializable ProxyScopeConfig dataclass) per scope rather than five independent scalar keys.

Fix in Claude Code

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