Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a29341b
refactor: move :migration:legacy-data to :legacy-migration
OffRange Aug 14, 2026
c0b260f
refactor: merge :migration:create-access into :legacy-migration
OffRange Aug 14, 2026
c0247e9
test: pin v1 on-disk identities across the module move
OffRange Aug 14, 2026
44904f5
feat(migration): add RunPendingMigrationUseCase
OffRange Aug 14, 2026
42e77b5
feat(auth): run the pending v1 migration before entering the app
OffRange Aug 16, 2026
d1bf820
fix(auth): keep the password error and guard against a double retry
OffRange Aug 16, 2026
fcaab7e
test(auth): cover the rejected v1 password keeping its field error
OffRange Aug 16, 2026
0b509a2
refactor(migration): confine the sequenced use cases to the module
OffRange Aug 16, 2026
255bf64
refactor(migration): drop the session-driven v1 import
OffRange Aug 16, 2026
e8f039b
refactor(security): remove Session.sessionStarts
OffRange Aug 16, 2026
c5b515c
docs: describe the consolidated :legacy-migration module
OffRange Aug 16, 2026
25b00eb
fix(auth): stop a second account creation from starting mid-flight
OffRange Aug 16, 2026
c1af9fe
test(auth): pin the migrate path staying loading until the account ex…
OffRange Aug 16, 2026
c47b9bc
refactor: cleanup
OffRange Aug 16, 2026
a1d904c
fix(migration): keep the main-password DataStore a structural singleton
OffRange Aug 17, 2026
ae5e010
fix(migration): keep the v1 marker while the legacy file has rows left
OffRange Aug 17, 2026
f5547d9
fix(auth): run the v1 import in an application scope, not viewModelScope
OffRange Aug 17, 2026
e8b4652
fix(auth): stop the end of a loading run from clobbering a newer state
OffRange Aug 17, 2026
554ddde
test(auth): drop returnDefaultValues by surfacing the import failure
OffRange Aug 17, 2026
489573f
refactor(migration): switch dispatchers where the blocking is, not ab…
OffRange Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ Android password manager using Clean Architecture per module:
| `:feature:*` (remaining) | `list_screen`, `item:{core,create,view}`, `credentials`, `totp`, `vault` |
| `:automation` | `@VaultItem` annotation + automation support |
| `:automation-processor` | KSP processor generating code from `@VaultItem` |
| `:migration:create-access` | Post-migration main-password/account setup (high risk) |
| `:migration:legacy-data` | Reads/decrypts the legacy v1 database for import (high risk) |
| `:legacy-migration` | The whole v1 to v2 migration: main password, item import (high risk) |
| `:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings |

## Key Patterns
Expand Down Expand Up @@ -127,8 +126,12 @@ carries its own rules:

## Sensitive Areas

- **Migration** (`migration:create-access`, `migration:legacy-data`) — preserve backward compat,
smallest safe change
- **Migration** (`legacy-migration`) — preserve backward compat, smallest safe change. The v1 main
password record is the marker for the whole migration and is cleared only by
`RunPendingMigrationUseCase`, only after the item import has returned a verdict. Four names are
the on-disk identity of shipped v1 data and must never change: `main-password.db`,
`secure_element_database`, `password_manager_skey`, and the `ProtoMainPassword` field numbers.
`OnDiskIdentityTest` pins all four.
- **Autofill** (`feature/autofill/`) — constrained by Android framework, keep conservative
- **UniFFI** — preserve memory and type safety across the FFI boundary.
- **Room schema** — check migration implications before changing entities
Expand Down
3 changes: 1 addition & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ dependencies {
implementation(projects.feature.settings)
implementation(projects.feature.backup)
implementation(projects.feature.onboarding)
implementation(projects.migration.createAccess)
implementation(projects.migration.legacyData)
implementation(projects.legacyMigration)

implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.core.ktx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package de.davis.keygo.app.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.davis.keygo.core.identity.domain.repository.AccountRepository
import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase
import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package de.davis.keygo.core.identity
import de.davis.keygo.core.identity.domain.model.Account
import de.davis.keygo.core.identity.domain.repository.AccountRepository
import de.davis.keygo.core.util.Result
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -14,16 +15,40 @@ class FakeAccountRepository : AccountRepository {

var setFails: Boolean = false

/**
* When set, [getOrNull] suspends on it and answers with whatever it is completed to.
*
* The only place a test can stand inside an unlock. Reading the account is the first thing
* `UnlockWithPasswordUseCase` does and everything after it hops to `Dispatchers.Default`, which
* the test scheduler cannot see, so this is the one suspension a test can both hold open and
* release on demand.
*/
var pendingRead: CompletableDeferred<Account?>? = null

/**
* How many accounts were actually persisted. A caller that mints a second account overwrites
* the first here exactly as the real registry does, so the stored value alone cannot tell the
* two apart.
*/
var setCount: Int = 0
private set

fun seed(account: Account) {
this.account.update { account }
}

override suspend fun getOrNull(): Account? = account.value
// Not an elvis over await(): completing the read with null is the point of it, and an elvis
// would quietly hand back the seeded account instead.
override suspend fun getOrNull(): Account? = when (val pending = pendingRead) {
null -> account.value
else -> pending.await()
}

override fun observe(): Flow<Account?> = account.asStateFlow()

override suspend fun set(account: Account): Result<Unit, Unit> {
if (setFails) return Result.Failure(Unit)
setCount++
this.account.update { account }
return Result.Success(Unit)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package de.davis.keygo.core.security.data

import de.davis.keygo.core.security.domain.Session
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.core.annotation.Single
import javax.security.auth.DestroyFailedException

Expand All @@ -12,22 +9,12 @@ internal class SessionImpl : Session {

private var _ark: ByteArray? = null

// Replayed so a collector wired up after an unlock still sees it, and buffered with
// DROP_OLDEST so emitting can never suspend or fail inside startSession.
private val _sessionStarts = MutableSharedFlow<Unit>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

override val sessionStarts: Flow<Unit> = _sessionStarts

override val ark: ByteArray
get() = _ark ?: throw IllegalStateException("No active session")

override fun startSession(ark: ByteArray) {
endSession()
_ark = ark
_sessionStarts.tryEmit(Unit)
}

override fun endSession() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
package de.davis.keygo.core.security.domain

import kotlinx.coroutines.flow.Flow

interface Session {

val ark: ByteArray

/**
* Emits once per [startSession], for work that has to happen on every unlock however the user
* got there.
*
* A session is started from the auth screen, from the autofill service and from both passkey
* activities. A caller that enumerated those instead would go stale the next time a fifth way
* in is added, and would do so silently.
*/
val sessionStarts: Flow<Unit>

fun startSession(ark: ByteArray)
fun endSession()
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package de.davis.keygo.core.security.data

import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
Expand Down Expand Up @@ -63,33 +58,4 @@ class SessionImplTest {
session.endSession()
session.endSession() // should not throw
}

/**
* What the v1 import's retry rests on: it is not enough for the first unlock to be announced,
* because the run it starts is the one that may have left work behind.
*/
@Test
fun `a later start reaches a collector that already handled an earlier one`() = runTest {
val starts = mutableListOf<Unit>()
session.sessionStarts.onEach { starts += it }.launchIn(backgroundScope)

session.startSession(generateArk())
runCurrent()
session.startSession(generateArk())
runCurrent()

assertEquals(2, starts.size)
}

/**
* Replayed so the work bound to an unlock still runs when its collector attaches after the
* fact. Nothing collecting must not be able to swallow the one signal that a v1 import, or
* anything wired here later, gets to hear.
*/
@Test
fun `a start is replayed to a collector that arrives afterwards`() = runTest {
session.startSession(generateArk())

session.sessionStarts.first()
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package de.davis.keygo.core.security.crypto

import de.davis.keygo.core.security.domain.Session
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow

/**
* A fake implementation of [Session] that provides a fixed DEK for testing purposes.
Expand All @@ -18,24 +15,13 @@ class FakeSession(
override val ark: ByteArray
get() = _ark ?: throw IllegalStateException("FakeSession not started")

private val _sessionStarts = MutableSharedFlow<Unit>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

override val sessionStarts: Flow<Unit> = _sessionStarts

init {
if (startOnConstruct) {
_ark = ByteArray(32) { it.toByte() }
_sessionStarts.tryEmit(Unit)
}
if (startOnConstruct) _ark = ByteArray(32) { it.toByte() }
}

override fun startSession(ark: ByteArray) {
_ark = ark
startSessionCalled = true
_sessionStarts.tryEmit(Unit)
}

override fun endSession() {
Expand Down
5 changes: 3 additions & 2 deletions feature/auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ dependencies {
implementation(projects.core.identity)
implementation(projects.core.item)
implementation(projects.core.ui)
implementation(projects.migration.createAccess)
implementation(projects.legacyMigration)

implementation(libs.androidx.navigation.compose)

testImplementation(projects.rust)
testImplementation(libs.robolectric)
testImplementation(testFixtures(projects.core.identity))
testImplementation(testFixtures(projects.core.item))
testImplementation(testFixtures(projects.core.security))
testImplementation(testFixtures(projects.rust))
testImplementation(testFixtures(projects.migration.createAccess))
testImplementation(testFixtures(projects.legacyMigration))
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -75,6 +76,79 @@ fun AuthContent(
}
}

is AuthState.ImportingLegacyData -> {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
) {
ContainedLoadingIndicator()

Text(
text = stringResource(R.string.importing_legacy_data),
style = MaterialTheme.typography.titleMedium,
)

Text(
text = stringResource(R.string.importing_legacy_data_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}

is AuthState.MigrationSummary -> {
Surface(modifier = Modifier.fillMaxSize()) {
AlertDialog(
onDismissRequest = {},
icon = {
Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null)
},
title = { Text(text = stringResource(R.string.migration_summary_title)) },
text = {
Text(
text = stringResource(
R.string.migration_summary_description,
state.skippedItems,
),
)
},
confirmButton = {
Button(onClick = { onEvent(AuthUIEvent.ContinueAfterMigration) }) {
Text(text = stringResource(R.string.continue_anyway))
}
},
)
}
}

is AuthState.MigrationFailed -> {
Surface(modifier = Modifier.fillMaxSize()) {
AlertDialog(
onDismissRequest = {},
icon = {
Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null)
},
title = { Text(text = stringResource(R.string.migration_failed_title)) },
text = { Text(text = stringResource(R.string.migration_failed_description)) },
confirmButton = {
Button(onClick = { onEvent(AuthUIEvent.RetryMigration) }) {
Text(text = stringResource(R.string.retry))
}
},
dismissButton = {
TextButton(onClick = { onEvent(AuthUIEvent.ContinueAfterMigration) }) {
Text(text = stringResource(R.string.continue_anyway))
}
},
)
}
}

is AuthState.Interactable -> InteractableAuthContent(
state = state,
onEvent = onEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fun AuthScreen(onSuccess: () -> Unit) {
keyId = KeyId.BiometricVaultKek,
mode = request.cryptoMode
).onSuccess {
viewModel.executeCreateAccessAndClearV1(request.password, it)
viewModel.executeCreateAccess(request.password, it)
}.onFailure {
Log.e("AuthScreen", "Failed to create cipher for biometric access: $it")
// TODO: show error
Expand All @@ -47,7 +47,7 @@ fun AuthScreen(onSuccess: () -> Unit) {
biometricUnlockAdapter.useAdapter {
biometricCryptoController.requestUnlockVault()
}.onSuccess {
currentOnSuccess()
viewModel.onSessionEstablished()
}
}
}
Expand Down
Loading
Loading