Skip to content

refactor(migration): simplify - #72

Merged
OffRange merged 20 commits into
v2from
refactor/simplify-migration
Aug 19, 2026
Merged

refactor(migration): simplify#72
OffRange merged 20 commits into
v2from
refactor/simplify-migration

Conversation

@OffRange

Copy link
Copy Markdown
Owner

No description provided.

The loading tail writes the scope's state back when the block returns, so
the Migrating submit path's direct write to _uiState was overwritten and a
rejected v1 main password stopped the spinner without marking the field.
Route it through the scope, the way the Login path already does.

Retry is a button the user reaches after a failure, so it can be tapped
twice before the first run publishes anything. Two concurrent imports would
read and write the same v1 rows, so hold the job and drop the second tap.
Drives the real Submit event, which needs a hash ValidateMainPasswordUseCase
can hex-decode. The other tests seed a placeholder and enter past validation.
The migrate path nested one loading inside another: the outer block returned
as soon as executeCreateAccess had launched, so loading went back to false
while key derivation was still running and Submit re-enabled for the whole of
it. onEvent gates on the state being interactable rather than on the loading
flag, so nothing below the button stopped a second run either.

Two runs mint two accounts, two ARKs and two vaults, and the second overwrites
the registry, leaving the first vault wrapped under an ARK nothing persists.

Flatten the migrate path onto the caller's loading scope, and hold the job so a
run that lands while another is in flight is dropped.
…ists

The flattening had no coverage: the existing test drives executeCreateAccess,
whose only production caller is the biometric callback, not the Submit path the
defect was on.

The two halves of the fix are coupled in a non-obvious direction. With the guard
in place but the nesting still there, the inner loading is the run the guard
drops, so no account is created at all. Verified by restoring the nesting: this
test then fails.

Also names the real reason the guard cannot eat the biometric hand-off. The
collector resumes inline inside trySend, so AuthScreen runs while the job is
still active; what saves it is requestCipher suspending.
Copilot AI lite review requested due to automatic review settings August 16, 2026 17:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OffRange OffRange left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review of the migration consolidation. Most of this reads as a clean simplification; the notes below are the places where behaviour changed in ways I do not think were intended. The RunPendingMigrationUseCase one is the only blocker in my view.

One cross-PR note that has no line to hang on here: #73 edits migration/legacy-data/.../LegacyItemConverter.kt to pass the now-mandatory passkeyRPs, while this PR moves that file to legacy-migration/. Git resolves rename-vs-edit in favor of this PR's content, so whichever of the two merges second will fail to compile with No value passed for parameter passkeyRPs. Worth agreeing a merge order, and re-checking any other Login(...) construction both branches touch.

Comment thread feature/auth/build.gradle.kts Outdated
OffRange and others added 6 commits August 17, 2026 21:59
The Migrated branch cleared the marker unconditionally, but the import returns
Migrated with fileRetained = true whenever the prune failed or the recount came
back non-zero. On that path the marker went and Completed(skippedItems = 0) sent
the user straight into the app.

hasMainPassword() is false from then on, so nothing ever deletes
secure_element_database or the password_manager_skey alias again: the v1
database stays on disk, still decryptable by a key still in the Keystore, with
no path that retries.

Gate the clear on LegacyMigrationOutcome.nothingLeftToImport, which is what it
was added for and until now was read nowhere. A retained file keeps the marker
and the next unlock tries again.

The trade this reverses is a prune that keeps failing over rows already in v2,
which reimports them each unlock. A duplicate is visible and undoable; a v1
database nothing can reach is neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the import onto viewModelScope changed two things beyond where the job is
held.

It put the work on the main thread. viewModelScope is Dispatchers.Main.immediate
and nothing below it switches: LegacyKeyRepositoryImpl reaches the Keystore,
LegacyAesGcmCipher decrypts per row, converter.convert re-encrypts every field
per item, and deleteDatabaseAndKey is not even suspending. On a vault of a few
hundred items that is seconds of StrictMode-violating work on the auth screen,
and an ANR at the top end.

It also opened a cancellation window that did not exist before. The batch write
commits before the prune runs, so a back press between the two - or a popped nav
entry, or a destroyed activity - cancelled the run. Cancellation is rethrown all
the way up on purpose, so the marker stayed set over rows already in v2 and the
next unlock imported them again under fresh ids: two copies of every v1 item.
On the old application-scoped job only process death could do that.

RunPendingMigrationUseCase owns the run instead. It is already the @single that
sequences the migration, so the scope and the one-run-at-a-time state have
somewhere to live without a class in between: it starts the run on an
application-lived IO scope and hands out a join on it. The ViewModel keeps the
states it grew - ImportingLegacyData, the summary, the failure - but what its
scope cancels is the waiting, not the import.

Callers that overlap share one run and one verdict, which is also how a screen
destroyed mid-import and rebuilt still reports the summary for a run it did not
start. A run that has already finished is not replayed, so Retry after a failure
really does retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The update that ends a loading run writes back a snapshot taken before block()
suspended, but lost the `if (it !is AuthState.Interactable) return@update it`
guard that the update starting the run still has.

onSessionEstablished is reachable from outside loading: AuthScreen calls it
straight from the BiometricRequest.Login success handler, and nothing gates it
on authJob. So the user submits the password form, the biometric prompt they had
already triggered succeeds, the import starts and puts the screen in
ImportingLegacyData, and this line then drops the stale Login snapshot back over
it. The import is left running invisibly behind a live login form that can be
submitted again.

Guard it the same way, and pin it: the test fails against the unguarded write.

FakeAccountRepository grows a held-open account read because it is the only
place a test can stand inside an unlock - everything after it hops to
Dispatchers.Default, which the test scheduler cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One Log.e call in AuthViewModel had turned returnDefaultValues on for every unit
test in the module. The cost is module-wide and open-ended: any future test here
that touches an unmocked android.jar method - TextUtils.isEmpty, Uri.parse,
Base64 - silently gets 0/null/false and passes against behaviour that would
crash on device, instead of failing loudly.

MigrationResult.Incomplete already carries the cause, so AuthState.MigrationFailed
carries it too and AuthScreen writes it out. The record survives, the branch is
now assertable on state rather than on a logger, and the flag goes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ove it

The import's scope carried Dispatchers.IO, which made every suspend function
under it main-safe by accident rather than by contract. A caller that did not
know to pick that scope - the autofill service, a future worker, a test - got
whatever thread it came in on, and the module's own signatures gave it no
reason to expect otherwise.

Push the switch down to the calls that actually block:

  - LegacyKeyRepository.secretKey/deleteLegacyKey become suspend on IO. Loading
    the Keystore reaches the keymaster HAL.
  - LegacyItemRepository.deleteDatabase becomes suspend on IO, and withDao runs
    there too. Room moves its own suspending queries, but databaseProvider.get()
    stats the path and builds the database, and the first query through it runs
    v1's 1/2-to-3 recreate. The provider is @synchronized and cannot itself be
    suspend, so the repository is as low as this goes.
  - readAll's row loop runs on Default. An AES-GCM open and a JSON parse per row
    is processor work, and it is the part that scales with the old vault.

The re-encryption was already main-safe through CryptographicScopeImpl, so it
needed nothing.

provideMigrationScope now names no dispatcher and carries only the SupervisorJob
and the application lifetime, which is the part no withContext can substitute
for. Pinning a dispatcher there as well would only hide the next call that
forgets to switch.

Behaviour is unchanged. Pinned by a test that fails if any of the repository's
switches is dropped, which nothing else would catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@OffRange
OffRange merged commit 19c2a08 into v2 Aug 19, 2026
9 checks passed
@OffRange
OffRange deleted the refactor/simplify-migration branch August 19, 2026 09:05
OffRange added a commit that referenced this pull request Aug 19, 2026
#72 moved LegacyItemConverter.kt to legacy-migration and its copy of the
Login(...) call had no passkeys argument, so whichever of the two branches
merged second would fail to compile now that the default is gone from Login.
#72 landed first, so the argument is carried over onto its relocated file here.

Co-Authored-By: Claude Opus 5 <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.

2 participants