Skip to content

5.8.0 — MVI architecture, autofill, authenticator codes and password history - #4

Open
aditya-I0063 wants to merge 25 commits into
aditya-190:masterfrom
aditya-I0063:release/5.8.0-architecture
Open

aditya-I0063 wants to merge 25 commits into
aditya-190:masterfrom
aditya-I0063:release/5.8.0-architecture

Conversation

@aditya-I0063

Copy link
Copy Markdown

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.

  • Preview, Detail and Settings converted in that order, the hard one first. Sheet and dialog visibility is modelled as nullable sub-objects rather than parallel booleans, which made two real bugs unrepresentable: swipe-to-delete no longer writes to the database before the user confirms, and the settings sheet can no longer show the privacy text after the language picker closes.
  • Splash and OnBoarding get an intent and an effect but no state type — they hold none, and an empty XState is ceremony.
  • The cross-feature UiEvents is deleted. It carried cases most screens could never emit, so every call site closed its when with else -> Unit; two of those survived on screens that had already converted, where a new effect case would have silently done nothing. Each when is now exhaustive and the compiler enforces it.
  • SettingsViewModel lost its non-UI work to three small collaborators: the locale fork, the two-locale keyword lookup, and BuildConfig.VERSION_NAME in place of a main-thread getPackageInfo at construction.

Features (Phase 5)

Autofill. The third onboarding screen has promised it since the app shipped and no AutofillService existed. 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 in AutofillMatcher, 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 updateDetail rather 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.previewId never had. Additive only, so an interrupted run has nothing half-done; its statements are copied verbatim from Room's exported 4.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

  • 118 unit tests, 23 instrumented tests, lintDebug — all green.
  • Minified assembleRelease installed 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.
  • Every @Serializable serializer confirmed present in mapping.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 MissingTranslation cannot see it.

🤖 Generated with Claude Code

aditya-I0063 and others added 25 commits September 16, 2026 08:05
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>
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