5.8.0 — MVI architecture, autofill, authenticator codes and password history - #4
Open
aditya-I0063 wants to merge 25 commits into
Open
aditya-I0063 wants to merge 25 commits into
aditya-I0063 wants to merge 25 commits into
Conversation
Onboarding titles/descriptions, Skip/Next and the splash tagline rendered in
English in all 16 locales. Root cause: 14 strings were marked
translatable="false" in values/strings.xml, which both excluded them from every
values-*/ folder and suppressed the MissingTranslation lint check that would
have caught it. The Kotlin was already using stringResource() correctly.
Translating the headings alone would have crashed the app on first launch:
TextHighlighter located the emphasised word with fullText.indexOf("Secure") and
passed the result straight to AnnotatedString.addStyle. For any translated
heading indexOf returns -1, and addStyle(start = -1, ...) throws. The highlight
is now marked up as [[word]] inside each string resource, so translators choose
the emphasised word in their own language, and a pure-Kotlin parser strips the
markers. Unbalanced or absent markers degrade to unstyled text instead of
throwing. Headings auto-size 36-64sp so longer languages do not clip.
Also in this release:
- Arabic was offered by the in-app picker but missing from locales_config.xml,
so selecting it silently did nothing. locales_config, values-*/ and the picker
now agree on all 17 locales.
- Saving a preview without renaming it matched itself in the duplicate-heading
check, reported "heading exists" and discarded the edit.
- DetailViewModel trimmed on create but not on edit, so trailing whitespace
crept into stored passwords.
- The bottom navigation announced the raw enum name ("BANKS") in every locale,
and the alpha-0 FAB spacer was reachable by TalkBack.
- The details screen's empty state read "No Previews Found".
- PasswordGenerator used kotlin.random.Random rather than SecureRandom, and did
not guarantee a character from each selected class.
- Removed 10 string resources that were unused in code (170 entries across all
locales), so they are not translated needlessly.
- proguard-rules.pro kept net.sqlcipher.**, but the shipped artifact is
net.zetetic:sqlcipher-android. Those rules matched nothing. The library
supplies its own consumer rules; verified against mapping.txt that all 59
net.zetetic classes are kept unrenamed without any app-side rule, so the dead
rules were removed rather than replaced.
MissingTranslation, ExtraTranslation, ImpliedQuantity and the string-format
checks are now build-breaking errors, which is what stops this recurring.
Verified on an API 36 emulator: onboarding launches without crashing in hi, ar,
de, ta, ja and en, with markers stripped and text localized. 16 unit tests pass,
lint passes, release build is green and the Room schema is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 of 5.6.0, landed on its own so the upgrade can be verified in isolation before any behavioural change is built on top of it. AGP 9 turned out to be a structural migration rather than a version bump, and two constraints forced deviations from the plan: - AGP 9 has built-in Kotlin support and rejects the 'org.jetbrains.kotlin.android' plugin outright, so that plugin is removed. Kotlin is still pinned through the compose-compiler plugin version, and the resolved compiler is 2.3.21 (verified via the buildscript classpath, which overrides AGP's bundled 2.2.10). - The plan targeted Kotlin 2.4.20, but no KSP release exists for the 2.4.x line: the newest KSP is 2.3.12, whose symbol-processing-api depends on kotlin-stdlib 2.3.20. Since Hilt and Room both run through KSP, the buildable target is the 2.3.x line. Kotlin is therefore 2.3.21, not 2.4.20. Also required, and not anticipated by the plan: - Hilt 2.57.2 failed against AGP 9 with "Android BaseExtension not found"; upgraded to 2.60.1. - androidx.hilt 1.4.0 requires compiling against API 37, so compileSdk moves 36 -> 37. targetSdk stays at 36: compiling against newer APIs does not opt the app into new runtime behaviour, and that is a separate decision. - google-services 4.5.0 and firebase-crashlytics 3.0.8 for AGP 9 compatibility. Library versions: Compose BOM 2026.09.00, core-ktx 1.19.0, activity-compose 1.13.0, appcompat 1.8.0, lifecycle 2.11.0, navigation-compose 2.10.1, coroutines 1.11.0, datastore 1.2.1, Room 2.8.5, sqlcipher-android 4.19.0, androidx.sqlite 2.7.1, reorderable 3.1.0, firebase-bom 34.19.0. Java and jvmTarget move 22 -> 21 (LTS). androidx.biometric deliberately stays at 1.1.0: despite its age it is the newest stable release, and 1.2.0/1.4.0 are alpha-only. It already supports CryptoObject, which is all the 5.7.0 key work needs, and an alpha does not belong in the auth path of a password manager. material-icons-extended is frozen upstream at 1.7.8 and is no longer BOM-managed, so it now carries an explicit version rather than resolving to nothing. Other build hygiene: - local.properties was loaded unconditionally, throwing FileNotFoundException on any machine or CI checkout without it. Now guarded. - BuildConfig.PASS_PHRASE renamed to LEGACY_PASS_PHRASE to make its purpose explicit ahead of the 5.7.0 key migration. Same value, so existing vaults still open; AppModule carries a FIXME describing the replacement. - Dropped androidx.core:core-splashscreen, which was declared but never used (installSplashScreen is never called and no splash theme references it). - Added lifecycle-runtime-compose, needed for collectAsStateWithLifecycle. - Added mockk, turbine, kotlinx-coroutines-test and a testImplementation line for truth, which was previously androidTest-only. - opencsv is retained despite being unused: Phase 3 adopts it for the legacy CSV reader. Verified: clean assembleDebug, assembleRelease and lintDebug all green, 16 unit tests pass, all 65 net.zetetic classes still kept unrenamed by the newer R8, and onboarding still renders correctly with no crash in hi and ar on an API 36 emulator. Release APK drops 28M to 13M from the newer R8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…zation Completes Phase 1 and brings §2.5 forward, since both rewrite the same code. ViewModels resolved strings eagerly via UiText.StringResource(...).asString(appContext). Below API 33 - which is most of this app's supported range, minSdk being 28 - AppCompatDelegate.setApplicationLocales applies the locale override per Activity only, so the injected Application context keeps the *system* locale and every string resolved there came back in the previous language after a user switched language in Settings. That is a second root cause of "the app renders in English", independent of the translatable="false" bug fixed in 5.5.2. UiEvents.ShowSnackBar now carries UiText unresolved and the screens resolve it against LocalContext, which does honour the override on every API level. UiText gains a PluralResource variant, and its subtypes are now data classes holding List<Any> rather than a class with a vararg array field, so effects compare by value - identity comparison would have silently broken the effect assertions planned for the ViewModel tests. The Add/Edit bottom sheet title was a *resolved English string* stored in SavedStateHandle. It is now a boolean the UI turns into text. Clipboard moves out of the ViewModels into a CopyToClipboard effect handled by the UI, which is also where it belongs given Android 10+ only lets a focused app write the clipboard. The new SecureClipboard sets EXTRA_IS_SENSITIVE so Android 13+ redacts the paste preview, and clears the clip after 30s - but only if the clip is still the one we wrote, checked by ClipDescription timestamp, so we never wipe something the user copied from another app meanwhile. Auto-clear is best-effort by construction and is documented as such: a delayed clear that fires while backgrounded is a silent no-op on API 29+. Copying an entry *heading* is marked non-sensitive; only the secret itself is. On Android 13+ the app's own "copied" snackbar is suppressed because the system shows its own confirmation. PreviewViewModel and DetailViewModel no longer take an Application at all. Remaining localization work from the plan: - All 19 hardcoded contentDescriptions addressed. Actionable controls get localized strings; decorative branding images get null, because a screen reader announcing "App Icon" next to a visible title is noise. - Hardcoded "Password Settings", "Length: N", "Invalid file selected." and the "$score%" percentage extracted - percent placement is locale-dependent. - First plurals resource in the project, replacing a singular-only "used N times". Quantity classes follow CLDR per language rather than mirroring English: Arabic gets six forms, Russian four, Spanish/French/Italian/Portuguese three, and Japanese/Korean/Chinese only "other". - 15 new strings plus the plural translated into all 16 locales. - Added values-night: the only theme hardcoded windowLightStatusBar=true against a Light parent, so night mode showed dark status-bar icons on a dark bar and flashed a white window on cold start. windowLightStatusBar is now unset so enableEdgeToEdge's SystemBarStyle.auto owns bar contrast, and an explicit windowBackground is defined for both modes. res/values/colors.xml was an empty <resources/> and now holds those. Lint caught a genuine defect during this work: context.getString inside a composable is not configuration-aware. Hoisted into composable scope. Verified: clean debug build, 16 unit tests, and lint all green; onboarding renders correctly in Hindi with no crash in both light and dark mode on an API 36 emulator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three pieces of security hygiene plus the repository support Phase 3 needs. FLAG_SECURE was set on the Activity window only. Compose Dialog and ModalBottomSheet are separate windows: under the currently resolved Compose versions their securePolicy defaults to SecureFlagPolicy.Inherit, so they do inherit it today and this was a latent risk rather than an active leak - but it breaks silently if a default changes or a sheet is ever hosted off a non-secure window, and the password-analysis sheet renders weak and reused passwords. All five ModalBottomSheets and all three dialogs now pin SecureFlagPolicy.SecureOn explicitly rather than relying on inheritance. DetailViewModel kept the in-progress entry title and answer in SavedStateHandle, including freshly generated passwords. SavedStateHandle is serialized into the saved-instance-state bundle, which the system persists to disk under /data/system_ce/<user>/ - so cleartext secrets were being written outside the encrypted database. Both move to plain in-memory MutableStateFlows. The trade is that a half-typed entry is lost to process death, which is the right side of that trade for a password manager. The Add/Edit flag stays in SavedStateHandle, being a boolean rather than vault content. Added PasskeyRepository.runInTransaction and deleteAll, backed by Room's withTransaction, and used them immediately to fix reordering: both Preview and Detail reorder wrote N sequence updates in a bare loop, so an interruption left the list partially renumbered. Phase 3's import needs the same primitive to stop a malformed row leaving a half-populated vault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Export wrote the entire vault as readable CSV into the public Downloads folder. The .passkey extension and application/passkey MIME type were cosmetic: the bytes were credentials in cleartext, visible to any app with media access and swept up by cloud backup tools. This is the single worst data-handling defect in the app. New .pkbak format: "PKBACKUP" magic, format version, KDF id and parameters, salt, nonce and length, followed by AES-256-GCM ciphertext over GZIPped JSON. The whole 51-byte header is passed to GCM as additional authenticated data, so the KDF identifier and its parameters are covered by the auth tag - otherwise an attacker could rewrite kdfId to name a weaker KDF and the file would still verify. There is a test for exactly that. Keys come from PBKDF2-HMAC-SHA512 at 600k iterations. Argon2id is stronger against GPU attack and is what the kdfId field exists to allow; it needs a native dependency and is planned alongside the 5.7.0 key work, at which point new files get kdfId 2 and this reader keeps handling kdfId 1. Files are written through SAF CreateDocument so the user picks the destination, using "wt" so an existing file is truncated rather than partially overwritten. The backup password is collected only after a destination is chosen, and entered twice with an explicit warning that it cannot be recovered. Import is hardened throughout: - format detection is by magic bytes, not filename. The old check matched a display name ending in "passkey", which would have rejected every new backup and trusted a user-supplied name to decide how to parse the contents. - the whole restore runs in one transaction, so a bad row part-way through a file can no longer leave a half-populated vault. - unknown categories map to OTHERS instead of Categories.valueOf throwing into a broad catch that silently abandoned the rest of the file. - field lengths, row counts and file size are bounded against a hostile file. - a wrong password and a corrupt file are deliberately indistinguishable, since GCM cannot tell them apart and pretending otherwise leaks whether a guess was close. The legacy plaintext CSV stays readable so existing backups still restore, now via opencsv - which was already a declared but entirely unused dependency - so quoted values containing commas parse correctly rather than being split blindly. Rows with the wrong column count are dropped and counted rather than aborting the import. The lossy "____" comma sentinel is un-escaped on read; nothing can recover a value that genuinely contained it, but that is what the file means. Only the reader survives; the CSV writer is gone. SettingsViewModel drops from 340 to 212 lines with the file I/O and MediaStore code removed. Manifest, now that SAF has removed the need for storage access: - READ_/WRITE_EXTERNAL_STORAGE deleted. They were capped at maxSdkVersion 28 while minSdk is 28, so they were live on exactly the oldest supported devices. - allowBackup="false". Platform backup would carry the SQLCipher database off the device, and with today's build-time key that copy is decryptable by anyone with the APK. backup_rules.xml and data_extraction_rules.xml were untouched IDE templates and are now correct regardless, so flipping the flag back cannot silently start uploading the vault. - INTERNET is declared explicitly. The app makes no network calls itself, but Firebase and Play in-app-update require it, and having it merged in invisibly from a library manifest is how the README came to claim otherwise. The merged manifest of a built APK also shows AD_ID and the AdServices permissions arriving from firebase-analytics, which the privacy rewrite has to disclose. - MainActivity is singleTask so a second instance cannot come up holding a stale unlocked session once app-lock lands. 24 new unit tests cover the crypto round-trip, tamper detection on both ciphertext and header, and the legacy CSV reader including the shapes the old exporter got wrong. Round-tripping a value containing commas, newlines, the ____ sentinel and emoji is now covered, none of which survived the old format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In-app updates were configured as AppUpdateType.IMMEDIATE - Play's blocking flavour - but were bypassable, because registerForActivityResult was given an empty callback. On RESULT_CANCELED or RESULT_IN_APP_UPDATE_FAILED nothing happened and the app continued straight into the vault, so a single back press dropped the gate for the whole session. It also blocked on every available update however trivial, since updatePriority and clientVersionStalenessDays were never consulted, and its FLEXIBLE branch was unreachable because updateType was a hardcoded val with no listener or completeUpdate() to make it work. Extracted into AppUpdateController, which: - treats a non-OK result from a required update as "still required" and shows a terminal screen with no path into the vault; - reserves IMMEDIATE for updatePriority >= 4 and uses FLEXIBLE once an update is a week stale, so ordinary releases no longer interrupt anyone. Note that inAppUpdatePriority can only be set through the Play Developer Publishing API at release-creation time and is immutable afterwards, so it belongs on the 5.7.0 release checklist rather than being discovered later; - implements FLEXIBLE properly with an InstallStateUpdatedListener registered in onStart and released in onStop, surfacing a non-blocking banner when the download completes. Deliberately not the blocking screen: a low-priority update that is merely ready should not lock someone out of their passwords, and calling completeUpdate() unprompted would restart the app from under them; - keeps the existing DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS resume, which was the one part of the original that was already right. Added a Remote Config min_supported_version_code gate. In-app updates are entirely client-side and do nothing for a sideloaded install or a device without Play, so this is the only mechanism that genuinely enforces a floor - and it is the kill switch if 5.7.0's key migration needs stopping mid-rollout. Failure to reach Remote Config leaves the app usable rather than bricking it. This has to ship in 5.6.0 precisely because 5.7.0 re-keys every existing user's database and is the release most worth forcing. Note it still does not remove the need to keep LEGACY_PASS_PHRASE indefinitely: a forced update shortens the tail of old installs but cannot eliminate it, since someone who never opens the app is never prompted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no re-lock at all. Once past the biometric screen, backgrounding the app and returning granted full access indefinitely, with no idle timeout, and SecurityViewModel's isAuthenticated flag was written but never read - so navigation was the only thing standing between a picked-up phone and every stored password. VaultSession now holds the unlocked state that the rest of the app observes, and AppLockObserver watches ProcessLifecycleOwner rather than an Activity, so a configuration change or an internal screen transition is not mistaken for leaving the app. Timings use SystemClock.elapsedRealtime, never currentTimeMillis: wall clock lets someone extend their own grace period by changing the device clock, and it jumps on NTP sync. Foreground idle is driven by Activity.onUserInteraction, which is dispatched before the window handles the event and therefore sees touches and key presses without competing with Compose gesture consumption - considerably more robust than wrapping the tree in a pointerInput. On lock the clipboard is cleared first, since once locked there is no UI left to offer that, and navigation pops to the security gate with popUpTo(0) so every screen holding vault content is destroyed along with its nav-scoped ViewModel. The re-lock navigation is deliberately gated on the lock reason rather than merely "not unlocked": ColdStart is the initial state, and treating it as a lock would yank new users out of onboarding straight into a biometric prompt. Timeout is user-configurable in Settings - Immediately / 15s / 30s (default) / 1m / 5m / Never - persisted in DataStore. "Never" is a genuine footgun in a password manager, so choosing it surfaces an explicit warning rather than hiding behind a neutral label. All six options and the warning are translated into all 16 locales. In this release the session tracks lock state only; the database key is still the build-time constant. In 5.7.0 VaultSession also owns the decrypted key, and locking will zero it rather than flipping a boolean. Verified on an API 36 emulator: onboarding is unaffected on a fresh install, and a background/foreground cycle does not crash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PasswordAnalyzer decided whether a row held a secret by checking the question for the hardcoded English substrings "password", "pin", "code" and "secret". Across the app's 16 non-English locales nothing ever matched, so totalPasswords came back 0 and strengthScore came back 100: every translated user was told their vault was perfect and had no weak or reused passwords. A feature that silently lies is worse than one that is absent. Two changes fix it. Details gains an isSecret column (Room 2 -> 3, purely additive with a default, so no table rebuild) which is set explicitly when a value comes out of the password generator. And keyword matching is no longer hardcoded: the caller supplies R.array.secret_field_keywords, now translated for all 16 locales, unioned across the default and current locale so entries labelled before a language switch keep being classified. Matching is case-folded and accent-stripped, so "Contraseña" matches "contrasena". A SQL backfill in the migration was considered and rejected: the only heuristic expressible there is English keyword matching, which is precisely the bug. Existing rows default to 0 and continue to be classified by the now-localized keywords. Two scoring defects fixed while here: - reuse was penalised per distinct duplicated value rather than per affected entry, so three copies of one password scored the same as two. - reusedPasswords was keyed by the plaintext password, and that map is held in ViewModel state and rendered by the analysis sheet. It is now keyed by a truncated SHA-256 digest; there is no reason for cleartext passwords to sit in a composition-scoped object. Also added an all-identical-characters check to the weak-password heuristic. 9 new unit tests cover the locale behaviour, the isSecret flag, accent-insensitive matching, the weak heuristic and the scoring caps. MigrationTester gains 2 -> 3 and a full 1 -> 3 chain test, since someone who skipped several releases upgrades straight to the newest build; all 6 instrumented tests pass on an API 36 emulator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l locales The in-app About, Privacy and Terms text, README.md and Privacy Policy.md all stated that the app requires no internet permission and shares nothing with third parties. That was false in the shipped APK. Dumping the merged manifest of a build shows INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK, AD_ID and the Play AdServices permissions all arriving from firebase-analytics and the Play update library. The repo's Privacy Policy.md simultaneously contradicted the in-app copy by describing IP address and device name collection. All three in-app texts are rewritten to be accurate and, deliberately, far shorter. The previous About text was several hundred words of marketing that also claimed LiveData and MVVM (the app uses Flow), dark mode (half-implemented until this release) and Autofill (still not implemented). Accurate, concise copy is both better for users and tractable to translate. The rewritten text states plainly: vault entries are never collected, transmitted or stored by us and cannot be recovered by us; Firebase Crashlytics, Analytics and Performance Monitoring plus Play in-app updates send crash diagnostics, usage events, performance traces and device/installation identifiers including an advertising ID to Google, and never have access to vault contents; backups are the user's responsibility once written; and the vault is excluded from Android cloud backup and device transfer. Privacy Policy.md is rewritten around the actual architecture with a per-component table of what each one sends and a permission-by-permission justification. README.md drops "Fully Offline : No internet permission required", gains a security model section, and states the current database-key limitation openly rather than letting the feature list imply protection the build does not yet provide. With the text corrected it is now safe to translate, which completes the decision to localize everything: about_message, privacy_message and terms_n_condition_message are translated into all 16 locales. app_name is the only remaining translatable="false" string, being the brand name. These three are long-form legal and descriptive text produced by machine translation. They should get a native-speaker review pass before release; the technical claims in them are accurate, but phrasing and legal register have not been checked by a human for each language. Version bumped to 5.6.0 (versionCode 45). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lots
The vault was encrypted with BuildConfig.PASS_PHRASE: one constant, identical on
every install, recoverable from the APK. The database was therefore protected
against someone who found the file alone and against nobody who also had the app.
Biometric unlock was decorative on top of that - the prompt carried no
CryptoObject, allowed BIOMETRIC_WEAK (which can never back a Keystore key), and
its only effect was setting a flag nothing read.
The key is now 32 bytes from SecureRandom, generated once per install and never
stored bare. It is wrapped three times, and any one slot opens the vault:
BIO Keystore key, per-use BIOMETRIC_STRONG, unwrapped through a CryptoObject.
Bound to the current enrolment, so changing fingerprints invalidates it -
which is the desired property for the everyday path.
CRED Keystore key, device credential, 30s validity. Deliberately NOT
invalidated by biometric enrolment, so the app self-heals after a
fingerprint change without ever creating an unauthenticated path.
REC Recovery password, Argon2id. Mandatory, because it is the only slot that
survives the user removing their device lock, which destroys every
auth-bound Keystore key. Without it there is a reachable state with
permanent, total data loss. KeySlotFile refuses to persist without it.
Unlock falls through BIO to CRED to REC. KeyPermanentlyInvalidatedException is
thrown by Cipher.init before any prompt is shown, so an invalidated enrolment is
detected silently and the user never sees a broken prompt; a successful CRED
unlock rebuilds the BIO slot behind their back.
Two hard constraints shaped this. androidx.biometric 1.1.0 throws
IllegalArgumentException below API 30 if DEVICE_CREDENTIAL is among the allowed
authenticators of a CryptoObject prompt, and minSdk is 28 - hence two distinct
prompts rather than one. And auth-bound Keystore keys cannot be generated at all
without a lock screen, so each Keystore slot is attempted independently: a device
with a PIN but no fingerprint still gets CRED, and a device with neither still
gets a working vault protected by the recovery password alone.
The re-key uses sqlcipher_export into a sidecar, not PRAGMA rekey. Rekey rewrites
every page in place with no journal protection across the operation, so an
interruption leaves an unopenable file; exporting keeps the original untouched
until a rename, which makes it atomic by construction. Key slots are persisted as
PENDING *before* the export - the reverse order has a window where the file is
re-keyed but the key exists nowhere.
Two bugs the instrumented tests caught, both of which would have shipped:
- ATTACH DATABASE does not accept a bind parameter for the path; passing one made
SQLite try to open a file literally named "?".
- the source connection is opened OPEN_READWRITE without CREATE, so ATTACH could
not create the target file. It is now created up front.
VaultDatabaseProvider replaces the eager @provides database, which forced an open
at ViewModel construction - before any authentication. The repository resolves
Room Flows through flatMapLatest over the current database, which is what lets
locking close it: collectors detach and re-subscribe instead of throwing
"attempt to re-open an already-closed object". Locking now actually closes the
database and zeroes the key bytes. The key is passed in SQLCipher raw-key form so
it skips a 256,000-round PBKDF2 on every open.
12 instrumented migration tests, including an explicit assertion that
user_version survives - sqlcipher_export does not copy it, and losing it makes
Room either re-run migrations against migrated tables or destructively recreate
them. 6 more cover the Argon2 native binding, which also gains explicit ProGuard
keeps: argon2kt ships no consumer rules and survives today only via the default
native-methods rule, which is too indirect to rely on in an unlock path.
Verified end to end on an API 36 emulator with no device lock - the hardest case,
since only the recovery slot can exist: the gate provisions, writes a REC-only
slot file, opens the vault, and the resulting database header is random bytes
where a plaintext SQLite file would read "SQLite format 3".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…apping Three remaining items, and one significant bug that on-device testing exposed. The bug first. Keystore wrapping keys were AES, and an auth-bound symmetric key requires authentication for *every* operation - including encryption. So wrapping the vault key during provisioning threw UserNotAuthenticatedException, which the per-slot runCatching swallowed, and installs silently ended up with only the recovery slot. The device showed REC alone even with a PIN set; an instrumented capability test confirmed isDeviceSecure=true and createCredentialKey succeeding, which narrowed it to the wrap rather than the key generation. The wrapping keys are now RSA-OAEP key pairs. The public key encrypts with no authentication, so the vault key can be wrapped at setup without prompting, while the private key requires authentication to decrypt. Wrapping is free, unwrapping is gated - which is the property the whole design assumed and did not have. MGF1 is pinned to SHA-1 in the OAEP spec despite the SHA-256 digest, because AndroidKeyStore records one digest for the key and applies it to OAEP itself; passing MGF1 SHA-256 fails on several implementations. Recovery password change (Settings): requires the current password rather than reusing an in-memory key. Proving knowledge of the old password is what stops someone with a briefly unlocked phone from silently replacing the recovery credential with their own, and it means the key never has to be retained for the session. Implemented as two steps over the existing password dialog. Rescue export: the un-migrated vault can now be read straight through the legacy key and written out as an encrypted backup. Offered in two places - before the re-key for upgrading users, and again on the migration failure screen. The original database is untouched by a failed export, so a user is never stuck behind a migration they cannot complete. The failure screen still deliberately offers no "reset". Two UI defects fixed while wiring that: the export-result Toast was being shown from composition rather than an effect, and the "export a backup first" button was rendered behind a non-dismissible dialog where it could never be tapped. Device verification on a wiped API 36 emulator with a known PIN and no enrolled fingerprint: - provisioning creates REC and CRED, and correctly skips BIO, which cannot be generated without an enrolled biometric - relaunch shows the credential prompt, the PIN unwraps the key through the CRED slot, and the vault opens - backgrounding past the 30s auto-lock re-prompts instead of showing entries 21 instrumented tests pass, including a KeyStoreWrapper capability test that reports what the device can actually do, since production deliberately swallows these failures and falls back. Still unverified on hardware: the BIO slot, meaning the CryptoObject-bound fingerprint unlock and the enrollment-change fall-through. Emulator fingerprint enrolment could not be driven reliably. That path needs a physical device with fingerprints enrolled before rollout. Version bumped to 5.7.0 (versionCode 46). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…action Mechanical moves and renames only - no behaviour change - so the diff reads as "things moved". Splitting it out keeps the MVI conversions that follow reviewable. The package layout had domain and data the wrong way round: the PasskeyRepository *interface* lived in data/ while its implementation lived in domain/, and all six ViewModels sat under domain/viewModels despite referencing navigation routes and UiText. The event types lived under domain/events while two of them imported androidx.compose.foundation.lazy.LazyListItemInfo, putting a Compose UI type in what was nominally the domain layer. Now: the repository interface is domain/repository, its implementation is data/repository, and each ViewModel and its event type sits beside the screen that uses it. DataStoreSource is replaced by a semantic PreferencesRepository. The old interface existed but was bypassed at the injection site - the concrete class was wired directly, so the abstraction bought nothing - and its generic Preferences.Key<T> surface leaked DataStore into the ViewModels, which had to know about DataStoreRepository.onBoardingKey just to read a boolean. Callers now ask for onboardingCompleted, selectedLanguageTag and autoLockTimeout. While rewriting it, two defects went with it: - the preferencesDataStore delegate was a *member* extension property on the repository class. That delegate creates one instance per file and per-instance use risks "There are multiple DataStores active for the same file"; it worked only because the repository happened to be a singleton. It is now file-scoped. - SplashViewModel collected the onboarding flag with collect() on a DataStore flow, which never completes, so it re-sent a Navigate effect on every later preference change - including a language switch. Now first(). AutoLockTimeout moves to domain/model and loses its @stringres label, which had domain depending on generated R. The UI maps values to text instead. File and class names that disagreed are aligned: PassKeyApplication.kt held PasskeyApplication, PasskeyDatabase.kt held PassKeyDatabase, PassKeyRepository.kt held PasskeyRepository, PreviewBottomNavigation.kt held MainBottomNavigation. The database rename needed a two-step git mv, being case-only on a case-insensitive filesystem, and Room's exported schema directory moved with it. The plan's flattening of presentation/screens/<feature>_screen to presentation/<feature> is deliberately skipped: it is pure churn across ~30 files and the inversion was the actual defect. Still outstanding after this: the repository interface continues to speak in Room @entity types, which is the next step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository interface spoke in Room @entity types, so every ViewModel and screen handled database rows directly - and handled their nullable primary keys, which is why `previewId!!` and `detailsId!!` appeared throughout. Room entities are renamed PreviewEntity/DetailsEntity and confined to the data layer. New domain Preview and Detail carry non-null ids. Crucially the *entities* are left alone: making their ids non-null would change the column's notNull flag, change Room's identity hash, and require a migration for no functional gain. The mappers assert instead, which is always safe because Room populates the primary key on every read. All 21 instrumented tests still pass, confirming the rename did not disturb the exported schema - Room keys it off table and column names, not the class name. The repository also gains intent-revealing methods. createPreview/createDetail return the new row id, so callers no longer construct a half-built entity with a null id and hope; updatePreview/updateDetail take a domain object that already has one. Categories is consolidated into domain Category. There were briefly two of them after the rename, and the old one carried a @stringres label that had domain depending on generated R. Labels now map to text in the presentation layer, same as AutoLockTimeout. Category.fromNameOrOther replaces the ad-hoc helper in the backup code - never valueOf, which throws on the untrusted contents of an imported file. Two of the edge cases the audit listed are fixed here because the change opened their files anyway: - duplicate detection was case-sensitive, so "Gmail" and "gmail" coexisted as separate entries and re-importing a differently-cased backup silently created duplicates. The lookup now uses COLLATE NOCASE. - previews are filtered by category in SQL via getPreviewsByCategory, rather than in the ViewModel. Reordering used to renumber a list already narrowed by both category and search text while the query ordered globally, so sequences collided across categories and ordering was quietly unstable. Also switched the preview list from SharingStarted.Lazily to WhileSubscribed(5s). Lazily keeps Room's invalidation observer alive for the ViewModel's whole life even while the screen is backgrounded. Domain now imports nothing but its own types and kotlinx coroutines - no Android, no Compose, no Room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every screen collected one-off effects with `LaunchedEffect(key1 = true) {
viewModel.uiEvents.collect { ... } }`, which is not lifecycle-aware. It kept
collecting while the app was backgrounded, so a snackbar could be consumed and
lost with no window to show it in.
ObserveAsEvents wraps repeatOnLifecycle(STARTED) instead. The
Dispatchers.Main.immediate hop inside it matters: without it, an effect emitted
just as the lifecycle drops below STARTED can be dispatched into a composition
that is already gone.
The producing side stays a Channel rather than moving to SharedFlow, on purpose.
When repeatOnLifecycle cancels the collector on STOP, undelivered items remain
buffered in the channel and are redelivered on restart; a SharedFlow would drop
them. This is the one part of the original architecture that was already right.
The channels do change capacity though. Channel() defaults to RENDEZVOUS, which
suspends the sender until a collector receives - harmless when collection never
stopped, but now a backgrounded screen has no active collector and the coroutine
that emitted the effect would block until the user returned. They are BUFFERED.
State collection moves from collectAsState to collectAsStateWithLifecycle for the
same reason: nine call sites were recomposing while backgrounded.
The one remaining LaunchedEffect(key1 = true) is the splash screen's logo
animation, which genuinely should run once and is not an effect collector.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Navigation was stringly typed. `Routes.DETAILS_PAGE + "?previewId=${...}"` appeared
in two ViewModels as independently maintained copies of one URL format, nothing
checked that the argument name matched the graph, and DetailViewModel read its
argument back with `savedStateHandle.get<Long>("previewId") ?: -1`. That sentinel
then needed a "something went wrong" branch for an id that could never legitimately
arrive.
Routes are now @serializable objects. UiEvents.Navigate carries a NavRoute instead
of a String, the graph declares `composable<NavRoute.Details>`, and the ViewModel
reads `savedStateHandle.toRoute<NavRoute.Details>().previewId` as a non-null Long.
The unreachable sentinel branch is gone with it.
SplashViewModel's `startDestination` is deleted. It was a mutable ViewModel field
fed into NavHost's startDestination, and changing that after first composition
recreates the graph and resets the back stack. Splash is now a constant start
destination and routes onward with the Navigate effect it was already emitting.
The plan flagged this as the highest-risk step: type-safe routes resolve through
generated kotlinx.serialization serializers, R8 can strip them, release is minified
and the project's history includes a ProGuard incident. So it was gated on a real
minified build rather than a debug one. The release APK was signed with the debug
key and installed, and the mapping file confirms every NavRoute$$serializer
survives. On device it walked Splash -> Onboarding -> Security gate -> Previews,
created an entry, and navigated into Details - the one route carrying a serialized
argument - with no crash.
That run also incidentally confirmed the 5.7.0 credential slot end to end on a
minified build: the device-credential prompt unwrapped the vault key and opened the
database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te data loss
The reference implementation for the pattern. Preview was chosen first of the real
screens because it contains every pattern the codebase needs - Room-backed list,
search filter, SavedStateHandle-persisted argument, bottom-sheet editor, confirm
dialog, swipe-to-dismiss, drag-reorder and all three effect types - and because
all three outstanding correctness bugs lived in it.
State was previously spread across three SavedStateHandle flows, a
MutableStateFlow, three `mutableStateOf` properties and a plain `var`. No single
value described what the screen was showing, and impossible combinations were
representable. There is now one PreviewState, one PreviewIntent, one PreviewEffect.
State is *derived* from independent sources rather than accumulated. Combining the
public state back into itself would be a self-feeding loop that only terminates
because StateFlow deduplicates equal values.
The data-loss bug is the reason this screen went first. Swiping deleted the row
immediately and re-inserted it if the user cancelled, so a process death while the
confirmation dialog was open lost the entry permanently. Swiping now only sets
`pendingDelete`; the row is filtered out of the displayed list and nothing is
written until the user confirms. Two unit tests assert the repository is untouched
across swipe and cancel, and deletion now removes the entry and its details in one
transaction - there is no foreign key cascade to do it.
Modelling improvements that fell out of the single-state shape:
- `editor: Editor?` collapses four fields - a sheet-open boolean, the sheet title,
the heading being typed, and the row being edited. The title used to be stored
as an already-resolved English string in SavedStateHandle, which was both a
localization bug and a modelling one.
- `isLoading` distinguishes "still loading" from "genuinely empty". stateIn emits
its initial value first, so the empty-list illustration flashed on every entry.
- intents carry plain indices; the old event type carried a Compose
LazyListItemInfo.
Also fixed the recomposition problem the plan identified. `onEvent = viewModel::onEvent`
allocates a fresh object on every recomposition, so the parameter was never equal
and every visible row recomposed on each keystroke in the search bar. It is now
hoisted through `remember(viewModel) { viewModel::onIntent }`.
11 reducer tests, all plain JVM - no Robolectric - because effects carry unresolved
UiText rather than resolved strings and the state is an ordinary data class. They
run against a hand-written FakePasskeyRepository backed by StateFlows rather than a
mock, since the assertions that matter are about what was *not* written.
One test needed UnconfinedTestDispatcher rather than Standard: these assert on
derived state immediately after dispatching an intent, and with a standard
dispatcher nothing has run by the time the assertion executes.
Verified on device: swipe shows the confirmation, cancel restores the entry, and no
crash. 55 unit tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d policy Follows the Preview pattern. Detail carried the same swipe-delete data-loss bug - the row was deleted immediately and re-inserted on cancel, so a process death while the confirmation dialog was open lost it permanently. Swiping now only marks the row pending; nothing is written until the user confirms. Two tests assert the repository is untouched across swipe and cancel. The password generator's five loose `mutableStateOf` fields become a PasswordPolicy domain value object, and the toggle intent is typed. It previously dispatched on the magic strings "Upper", "Lower", "Number" and "Special" - a typo in either the sheet or the ViewModel would have silently done nothing. Turning off the last remaining character class now falls back to lowercase in the state itself, so "every switch off" is unrepresentable rather than merely guarded inside the generator. Trimming is applied on both the create and edit paths. Only create trimmed before, so a trailing space crept into stored passwords on edit and then failed silently wherever it was pasted. `wasGenerated` is cleared when the user types over a generated value, so an answer is only marked isSecret when it genuinely came from the generator - which is what makes the password analyser correct outside English. 12 reducer tests. This one class runs under Robolectric, unlike every other unit test in the module: SavedStateHandle.toRoute builds an android.os.Bundle to decode the typed route argument, which a plain JVM test cannot do. The alternative was to read the argument by string name in production and give up the type safety the previous commit introduced, so the test carries the weight instead. Robolectric is pointed at a stock Application, because the real one loads the SQLCipher native library in onCreate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extracts the non-UI work first: AppLocaleManager (the API 33+ LocaleManager fork), SecretKeywordProvider (the two-locale resource lookup) and AppInfo, which reads VERSION_NAME from BuildConfig instead of calling getPackageInfo on the main thread at ViewModel construction. The screen's two sheets were an isSheetOpen boolean plus a separate bottomSheetOpenedBy that could disagree, and closing the language picker revealed whichever legal text had been opened last. They are now one nullable Sheet with Language and Info(topic) cases, so that state is unrepresentable. The tri-state recoveryChangeStep: Boolean? becomes a named enum, and the analysis result carries its own visibility. Also here, because the same files were open: - Rating the app is an effect. It was started from the Application context with FLAG_ACTIVITY_NEW_TASK and no handling for a device without Play, which threw ActivityNotFoundException; the UI now starts it from the Activity and reports store_unavailable instead. - The recovery password held between the two dialog steps is zeroed when the flow is abandoned and in onCleared, not only on success. - Password analysis moves to an injected @DefaultDispatcher, which is what makes it assertable in a test. - Language.comingSoon was false for all 17 entries and nothing read it; deleted along with coming_soon in all 17 locales. - SettingsScreen's onPopBackStack was wired but no effect ever popped. Nine SettingsViewModel tests, 76 unit tests passing; clean assembleDebug, testDebugUnitTest and lintDebug green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Events Both screens are genuinely stateless - one intent, one navigation effect - so they get a contract with an intent and an effect and no state type. An empty XState would be ceremony, not architecture. What this does buy is the last use of the cross-feature UiEvents, which carried ShowSnackBar, CopyToClipboard and PopBackStack cases that neither screen can ever emit. Every call site had to close its `when` with an `else -> Unit`, and two of those branches survived on the preview and detail screens after those converted to their own sealed effects - so a new effect case could be added there today and silently do nothing. With UiEvents gone each `when` is exhaustive over its own type and the compiler enforces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third onboarding screen has said "Don't Type, Autofill Your Credentials" since the app shipped and no AutofillService existed anywhere in the code. This is that feature. The vault is encrypted and usually locked, so a fill request has two outcomes. Open: datasets built from entries whose heading names the same service as the requesting package or web domain. Locked: a single authentication entry that opens AutofillUnlockActivity and comes back with the datasets - deliberately carrying no entry names, since the suggestion list is drawn over another app's window and a locked vault must not leak which accounts it holds. The traversal of the assist structure is the only Android-bound part. Everything that is a decision - which entries match a package or domain, and which of their values are the username and the password - lives in AutofillMatcher, has no Android types, and has 11 unit tests. Two of those decisions are worth naming: a name is reduced to its identifying token, so com.google.android.gm and accounts.google.com both find a Google entry; and the username is the first non-secret value rather than a keyword match, because matching words like "user" or "email" is exactly what made the password analyser report a perfect vault in 16 locales. Saving works only while the vault is open - writing needs the key and a save request cannot authenticate - and says so rather than dropping the credential silently. A saved password is marked isSecret, so the analyser never has to guess for it. Also deletes ExampleUnitTest and ExampleInstrumentedTest, the IDE templates Phase 8 lists as dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Room 3 to 4, one migration, three changes. details_table gains the index on previewId that the plan has listed since the audit: every lookup of an entry's details filters on it and each one was a full table scan. It does not gain the foreign key - that means rebuilding the table holding every password the user owns, and the cascade is already enforced in one transaction by the repository. The two new tables are new, so they carry real foreign keys with ON DELETE CASCADE from the start. The migration is additive only - one index and two CREATE TABLE IF NOT EXISTS - so an interrupted run has nothing half-done and a retry is a no-op. Its statements are copied verbatim from Room's exported 4.json, because they have to match it exactly or the identity check fails on the first open after upgrading and no install starts. Four instrumented tests cover it, including running the migration twice and the 1-to-4 chain a long-dormant user hits. TOTP itself is RFC 6238, and all six SHA-1 test vectors plus the SHA-256 and SHA-512 ones are asserted, along with a Base32 decoder deliberately tolerant of how secrets are actually printed - grouped, lowercase, unpadded - and an otpauth:// parser that rejects HOTP rather than generating codes that could never verify. Password history is written by the repository inside updateDetail rather than by its callers. There are three of them - the editor, import, and autofill's save - and a history that depends on each one remembering is a history with holes in it. Capped at ten versions per secret. The backup format carries both, and gains `isSecret`, which it should have carried all along: without it a restore reset every entry to keyword matching, which classifies nothing outside English. Payload schema 2, and the file format version goes to 3 alongside it so an older build reports "made by a newer version" rather than failing on an unknown key and blaming the user's password. Schema 1 files still restore. 103 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authenticators appear above the entry list with their code, the label they came with, and a ring counting down to the next one; tapping copies the code as sensitive, so Android redacts the paste preview and the clipboard clears itself. The per-second clock lives in the card rather than in DetailState: a code is derived from the secret and the time, not state anyone edits, and a per-second emission in the screen's state would recompose the whole entry list once a second for a value only one row shows. Adding one takes either the otpauth:// link behind a QR code or the Base32 secret printed beside it. There is no scanner, deliberately: that means a camera permission in an app whose proposition is that it asks for almost none, and every setup page showing a QR code offers the same secret as text. History is offered only on rows the app knows are secrets, which is exactly where the repository writes it. It is read-only and each row copies rather than restores - a password that was replaced was usually replaced for a reason, and putting it back with one tap is the wrong default. Copying still covers the case that happens in practice, a site that has not caught up. Seven more ViewModel tests, including that a non-secret detail records no history: keeping every version of a username is storing plaintext nobody asked to keep. 110 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version 5.8.0, code 47. The README still carried the 5.7.0 "known limitation" notice saying the database key is a build-time constant. That shipped; the section now describes the three-slot key hierarchy that replaced it, and the one-time re-key of older vaults. The in-app About text and the README feature list gain autofill, authenticator codes and password history. All 16 translations of the About text were updated in the same pass - a base string edited without them is the stale-copy bug this release set out to fix, and MissingTranslation cannot see it because the translations exist, they are just behind. The privacy policy gains an Autofill section. It is the one feature that makes Android show the app something from outside it - the structure of the form being filled - so it says plainly that this happens on the device and that a locked vault offers no entry names at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backup file is the only copy of a vault that leaves the device, and until now only its pieces were tested: the crypto, the payload schema, and the legacy CSV parser, each on its own. A field silently dropped between them would not have failed anything. Eight tests drive the real export and import through a content URI, and assert what matters about the file rather than only that it round-trips: that the written bytes contain none of the entry text, that a wrong password and a corrupt file are reported identically - distinguishing them would tell someone holding the file when they had guessed the password right - that importing the same file twice adds nothing the second time, and that isSecret and authenticator parameters both survive. Robolectric is used only for the ContentResolver; everything under test is the app's own code. 118 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uards **One list of languages.** The picker declared 17 entries inline inside a composable, locales_config declared 17 more, and the values-* folders were a third list. They had already drifted once - that drift *was* the Arabic bug, where the language was offered and translated but missing from locales_config, so choosing it silently did nothing. Adding the missing line fixed the symptom; an AppLanguage enum removes the shape that allowed it, and the Language data class is gone. **LocalizationCoverageTest**, which the plan asked for and nothing had delivered. MissingTranslation catches a key absent from a folder; it cannot catch a language the picker offers with no folder behind it, or a locale missing from locales_config. Writing it found one thing worth keeping: the Italian for "Privacy" is "Privacy", so legitimate coincidences are an explicit allowlist rather than a weaker assertion. **AppUpdateControllerTest**, six tests against Play's own FakeAppUpdateManager (which ships inside app-update; there is no separate testing artifact). The update gate cannot be exercised for real from a local build and is what a hotfix depends on, and the bug it replaced was a silent fall-through, so the assertions are mostly about refusal. The first version raced Play's async callback and passed alone but failed in the full suite; it now waits, and the two negative cases settle first so they cannot pass for the wrong reason. **SecureModalBottomSheet** plus a check task. Every sheet already passed SecureFlagPolicy.SecureOn individually, so this fixes no live leak - it removes the chance to forget. The script is wired to `check`, because a guard nobody runs is not a guard. Also `setRecentsScreenshotEnabled(false)` on API 33+, so the recents thumbnail is blacked out deliberately rather than as a side effect of FLAG_SECURE. 118 unit tests, 32 instrumented tests, three consecutive clean runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #3 (5.7.0). The last release in the plan: no key material changes, so it runs at ordinary risk.
Architecture (Phase 4)
Every screen is now a single immutable state, a sealed intent and a per-feature effect.
XStateis ceremony.UiEventsis deleted. It carried cases most screens could never emit, so every call site closed itswhenwithelse -> Unit; two of those survived on screens that had already converted, where a new effect case would have silently done nothing. Eachwhenis now exhaustive and the compiler enforces it.SettingsViewModellost its non-UI work to three small collaborators: the locale fork, the two-locale keyword lookup, andBuildConfig.VERSION_NAMEin place of a main-threadgetPackageInfoat construction.Features (Phase 5)
Autofill. The third onboarding screen has promised it since the app shipped and no
AutofillServiceexisted. A locked vault offers one "unlock" suggestion and no entry names — the list is drawn over another app's window. Everything that is a decision rather than a traversal lives inAutofillMatcher, which has no Android types and 11 tests.Authenticator codes. RFC 6238, all six SHA-1 vectors plus SHA-256 and SHA-512 asserted. Added from an
otpauth://link or a typed secret; no camera permission, because every setup page that shows a QR code also prints the same secret.Password history. Written by the repository inside
updateDetailrather than by its three callers, capped at ten versions, offered only on rows the app knows are secrets. Read-only, and each row copies rather than restores.Room 3 → 4 carries all of it plus the index
details_table.previewIdnever had. Additive only, so an interrupted run has nothing half-done; its statements are copied verbatim from Room's exported4.json.The backup format carries authenticators and gains
isSecret, which it should have carried all along — without it a restore reset every entry to keyword matching, which classifies nothing outside English. Schema 2, format version 3 so an older build says "made by a newer version" instead of blaming the password. Schema 1 files still restore.Verification
lintDebug— all green.assembleReleaseinstalled and driven on an emulator: onboarding, recovery provisioning, vault creation, type-safe navigation into details, and adding an authenticator. The code it displayed matched an independent HMAC computation for the same time window.@Serializableserializer confirmed present inmapping.txt, so no new R8 keeps were needed.Docs and the in-app About text were updated in the same pass, all 16 translations included — a base string edited without them is the stale-copy bug this whole plan set out to fix, and
MissingTranslationcannot see it.🤖 Generated with Claude Code