From 6b5bc004324d83d1b85fd49e8cfee32d44dfea45 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 4 Jun 2026 17:54:54 +0200 Subject: [PATCH 01/27] feat(settings): add group-entry DSL --- feature/settings/build.gradle.kts | 11 ++ feature/settings/consumer-rules.pro | 0 .../settings/di/FeatureSettingsModule.kt | 10 ++ .../settings/presentation/SettingsContent.kt | 71 ++++++++ .../settings/presentation/SettingsScreen.kt | 16 ++ .../settings/presentation/SettingsUiEvent.kt | 7 + .../settings/presentation/SettingsUiState.kt | 6 + .../presentation/SettingsViewModel.kt | 13 ++ .../presentation/component/SettingsDsl.kt | 73 ++++++++ .../presentation/component/SettingsEntry.kt | 42 +++++ .../presentation/component/SettingsList.kt | 170 ++++++++++++++++++ .../settings/src/main/res/values/strings.xml | 7 + .../presentation/component/SettingsDslTest.kt | 62 +++++++ settings.gradle.kts | 1 + 14 files changed, 489 insertions(+) create mode 100644 feature/settings/build.gradle.kts create mode 100644 feature/settings/consumer-rules.pro create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt create mode 100644 feature/settings/src/main/res/values/strings.xml create mode 100644 feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 00000000..210a4725 --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.keygo.android.compose) +} + +android { + namespace = "de.davis.keygo.feature.settings" +} + +dependencies { + implementation(projects.core.ui) +} diff --git a/feature/settings/consumer-rules.pro b/feature/settings/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt new file mode 100644 index 00000000..9566bc0f --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/di/FeatureSettingsModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.settings.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.settings") +object FeatureSettingsModule \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt new file mode 100644 index 00000000..e1e8734f --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -0,0 +1,71 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.settings.R +import de.davis.keygo.feature.settings.presentation.component.SettingsList + +@Composable +internal fun SettingsContent( + state: SettingsUiState, + onEvent: (SettingsUiEvent) -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + SettingsList( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + contentPadding = innerPadding, + ) { + section(title = R.string.settings_security) { + action( + title = R.string.settings_reset_password, + icon = Icons.Default.LockReset, + onClick = { onEvent(SettingsUiEvent.ResetPassword) }, + ) + + toggle( + title = R.string.settings_use_biometrics, + icon = Icons.Default.Fingerprint, + checked = state.biometricsEnabled, + onCheckedChange = { onEvent(SettingsUiEvent.SetBiometrics(it)) }, + ) + + toggle( + title = R.string.settings_autofill, + icon = Icons.Default.Password, + checked = state.biometricsEnabled, + onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, + ) + } + } + } +} + +@Preview +@Composable +private fun SettingsContentPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + SettingsContent( + state = SettingsUiState(), + onEvent = {}, + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt new file mode 100644 index 00000000..bb32a519 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.androidx.compose.koinViewModel + +@Composable +fun SettingsScreen() { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + SettingsContent( + state = state, + onEvent = {} + ) +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt new file mode 100644 index 00000000..3f5e64ed --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.settings.presentation + +internal sealed interface SettingsUiEvent { + data class SetBiometrics(val enabled: Boolean) : SettingsUiEvent + data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent + data object ResetPassword : SettingsUiEvent +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt new file mode 100644 index 00000000..8ac0abf8 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.settings.presentation + +internal data class SettingsUiState( + val autofillEnabled: Boolean = false, + val biometricsEnabled: Boolean = false, +) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt new file mode 100644 index 00000000..45786d49 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class SettingsViewModel : ViewModel() { + + private val _state = MutableStateFlow(SettingsUiState()) + val state = _state.asStateFlow() +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt new file mode 100644 index 00000000..7e139787 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +@DslMarker +internal annotation class SettingsDsl + +@SettingsDsl +internal class SettingsScope { + private val sections = mutableListOf() + + fun section(@StringRes title: Int, block: SectionScope.() -> Unit) { + sections += SettingsSection(title, SectionScope().apply(block).build()) + } + + fun build(): List = sections.toList() +} + +@SettingsDsl +internal class SectionScope { + private val entries = mutableListOf() + + fun toggle( + @StringRes title: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: ImageVector? = null, + @StringRes supporting: Int? = null, + ) { + entries += SettingsEntry.Toggle( + title = title, + icon = icon, + supporting = supporting, + checked = checked, + onCheckedChange = onCheckedChange, + ) + } + + fun action( + @StringRes title: Int, + onClick: () -> Unit, + icon: ImageVector? = null, + @StringRes supporting: Int? = null, + isNavigation: Boolean = false, + ) { + entries += SettingsEntry.Action( + title = title, + icon = icon, + supporting = supporting, + isNavigation = isNavigation, + onClick = onClick, + ) + } + + fun value( + @StringRes title: Int, + value: String, + icon: ImageVector? = null, + @StringRes supporting: Int? = null, + onClick: (() -> Unit)? = null, + ) { + entries += SettingsEntry.Value( + title = title, + icon = icon, + supporting = supporting, + value = value, + onClick = onClick, + ) + } + + fun build(): List = entries.toList() +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt new file mode 100644 index 00000000..6de73adc --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +internal sealed interface SettingsEntry { + @get:StringRes + val title: Int + val icon: ImageVector? + + @get:StringRes + val supporting: Int? + + data class Toggle( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + ) : SettingsEntry + + data class Action( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val isNavigation: Boolean = false, + val onClick: () -> Unit, + ) : SettingsEntry + + data class Value( + @param:StringRes override val title: Int, + override val icon: ImageVector? = null, + @param:StringRes override val supporting: Int? = null, + val value: String, + val onClick: (() -> Unit)? = null, + ) : SettingsEntry +} + +internal data class SettingsSection( + @param:StringRes val title: Int, + val entries: List, +) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt new file mode 100644 index 00000000..1054b3da --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -0,0 +1,170 @@ +package de.davis.keygo.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SettingsList( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + content: SettingsScope.() -> Unit, +) { + val sections = SettingsScope().apply(content).build() + + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + sections.forEach { section -> + stickyHeader(key = "header_${section.title}") { + SettingsSectionHeader(title = section.title) + } + + itemsIndexed( + items = section.entries, + key = { index, entry -> "${section.title}_${index}_${entry.title}" }, + ) { index, entry -> + SettingsEntryRow( + entry = entry, + shapes = ListItemDefaults.segmentedShapes(index, section.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + verticalAlignment = Alignment.CenterVertically, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SettingsEntryRow( + entry: SettingsEntry, + shapes: ListItemShapes, + colors: ListItemColors, + verticalAlignment: Alignment.Vertical +) { + val leadingContent: (@Composable () -> Unit)? = entry.icon?.let { icon -> + { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.size(40.dp), + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + } + } + } + } + val supportingContent: (@Composable () -> Unit)? = entry.supporting?.let { res -> + { Text(text = stringResource(res)) } + } + val headlineContent: @Composable () -> Unit = { Text(text = stringResource(entry.title)) } + + when (entry) { + is SettingsEntry.Toggle -> SegmentedListItem( + onClick = { entry.onCheckedChange(!entry.checked) }, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = { + Switch( + checked = entry.checked, + onCheckedChange = null, + thumbContent = { + Icon( + imageVector = when { + entry.checked -> Icons.Default.Check + else -> Icons.Default.Close + }, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + ) + }, + content = headlineContent, + ) + + is SettingsEntry.Action -> SegmentedListItem( + onClick = entry.onClick, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = if (entry.isNavigation) { + { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + } + } else null, + content = headlineContent, + ) + + is SettingsEntry.Value -> SegmentedListItem( + onClick = entry.onClick ?: {}, + enabled = entry.onClick != null, + shapes = shapes, + colors = colors, + leadingContent = leadingContent, + supportingContent = supportingContent, + verticalAlignment = verticalAlignment, + trailingContent = { Text(text = entry.value) }, + content = headlineContent, + ) + } +} + +@Composable +private fun SettingsSectionHeader( + @StringRes title: Int, + modifier: Modifier = Modifier, +) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(top = 8.dp, bottom = 4.dp), + ) +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 00000000..b3b4f1d7 --- /dev/null +++ b/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Security + Use Biometrics + Reset Password + Autofill + diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt new file mode 100644 index 00000000..988fbdae --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.settings.presentation.component + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SettingsDslTest { + + @Test + fun `builder groups entries into sections in declaration order`() { + val sections = SettingsScope().apply { + section(title = 1) { + toggle(title = 10, checked = true, onCheckedChange = {}) + action(title = 11, onClick = {}) + } + section(title = 2) { + value(title = 20, value = "2.1") + } + }.build() + + assertEquals(2, sections.size) + assertEquals(1, sections[0].title) + assertEquals(listOf(10, 11), sections[0].entries.map { it.title }) + assertEquals(2, sections[1].title) + assertEquals(listOf(20), sections[1].entries.map { it.title }) + } + + @Test + fun `toggle entry captures checked state and callback`() { + var observed = false + val entry = SectionScope().apply { + toggle(title = 10, checked = true, onCheckedChange = { observed = it }) + }.build().single() + + val toggle = assertIs(entry) + assertTrue(toggle.checked) + toggle.onCheckedChange(true) + assertTrue(observed) + } + + @Test + fun `action entry marked as navigation when requested`() { + val entry = SectionScope().apply { + action(title = 11, onClick = {}, isNavigation = true) + }.build().single() + + val action = assertIs(entry) + assertTrue(action.isNavigation) + } + + @Test + fun `conditional rows are excluded when condition is false`() { + val show = false + val entries = SectionScope().apply { + action(title = 11, onClick = {}) + if (show) action(title = 12, onClick = {}) + }.build() + + assertEquals(listOf(11), entries.map { it.title }) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fea9b97d..6a41b2b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,3 +47,4 @@ include(":feature:vault") include(":feature:auth") include(":feature:autofill") include(":feature:credit-card") +include(":feature:settings") From 3109fefdaa23f9a20a8d1c06936b74720667555c Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 4 Jun 2026 22:05:05 +0200 Subject: [PATCH 02/27] feat(app): wire SettingsScreen into the settings nav destination Replace the Text("SETTINGS") placeholder with the real SettingsScreen and add the :feature:settings dependency. Additionally, remove the unused connectivity route placeholder. --- app/build.gradle.kts | 1 + .../kotlin/de/davis/keygo/app/presentation/MainActivity.kt | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4466c4b1..48d35df7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,6 +112,7 @@ dependencies { implementation(projects.feature.totp) implementation(projects.feature.creditCard) implementation(projects.feature.autofill) + implementation(projects.feature.settings) implementation(projects.migrationCreateAccess) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index bea71aad..597f7562 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -5,7 +5,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator @@ -35,6 +34,7 @@ import de.davis.keygo.dashboard.presentation.DetailType import de.davis.keygo.dashboard.presentation.dashboardGraph import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.settings.presentation.SettingsScreen import de.davis.keygo.item.dialog.SelectItemContent import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -155,11 +155,8 @@ private fun App() { dashboardGraph(listNavigator = listNavigator) } - composable { - Text("CONNECTIVITY") - } composable { - Text("SETTINGS") + SettingsScreen() } } } From 58cafc179a5103e6eb556799756c356a4f18f667 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 4 Jun 2026 22:06:09 +0200 Subject: [PATCH 03/27] fix(auth): add serialization plugin again --- feature/auth/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index f73293e1..837958d4 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { From 4248f2c1a61ff1b293c6b994d376942c1d7b95e0 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 5 Jun 2026 15:49:09 +0200 Subject: [PATCH 04/27] feat(autofill): add autofill service repo Allows getting enabled state --- .../repository/AutofillServiceRepositoryImpl.kt | 15 +++++++++++++++ .../keygo/feature/autofill/di/AutofillModule.kt | 6 ++++++ .../repository/AutofillServiceRepository.kt | 7 +++++++ 3 files changed, 28 insertions(+) create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt create mode 100644 feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt new file mode 100644 index 00000000..7fc4b3b2 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/AutofillServiceRepositoryImpl.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.autofill.data.repository + +import android.view.autofill.AutofillManager +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import org.koin.core.annotation.Single + +@Single +internal class AutofillServiceRepositoryImpl( + private val autofillManager: AutofillManager, +) : AutofillServiceRepository { + + override fun isEnabled(): Boolean = autofillManager.hasEnabledAutofillServices() + + override fun disable() = autofillManager.disableAutofillServices() +} \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt index 6977da1d..2ff2923f 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/di/AutofillModule.kt @@ -2,6 +2,8 @@ package de.davis.keygo.feature.autofill.di import android.content.Context import android.os.Build +import android.view.autofill.AutofillManager +import androidx.core.content.getSystemService import de.davis.keygo.core.identity.di.CoreIdentityModule import de.davis.keygo.core.util.di.CoreUtilModule import de.davis.keygo.feature.autofill.presentation.dataset.DatasetBuilderApi33Impl @@ -34,4 +36,8 @@ object AutofillModule { ) ) .build() + + @Single + internal fun provideAutofillManager(applicationContext: Context) = + applicationContext.getSystemService() } \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt new file mode 100644 index 00000000..6061bee7 --- /dev/null +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/AutofillServiceRepository.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.autofill.domain.repository + +interface AutofillServiceRepository { + + fun isEnabled(): Boolean + fun disable() +} \ No newline at end of file From 0abcb37bd348dbefa1b310cdca3ade99a4bc28ff Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 5 Jun 2026 15:52:04 +0200 Subject: [PATCH 05/27] build: api(projects.core.security) --- app/build.gradle.kts | 1 - core/identity/build.gradle.kts | 2 +- feature/auth/build.gradle.kts | 1 - feature/autofill/build.gradle.kts | 1 - feature/credentials/build.gradle.kts | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48d35df7..4e2bee2e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,7 +100,6 @@ dependencies { implementation(projects.rust) implementation(projects.core.item) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.core.ui) implementation(projects.feature.auth) implementation(projects.feature.listScreen) diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index 36d81fcc..d17b65b0 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -12,7 +12,7 @@ android { } dependencies { - implementation(projects.core.security) + api(projects.core.security) implementation(projects.core.item) implementation(projects.rust) diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 837958d4..0122bec5 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -8,7 +8,6 @@ android { } dependencies { - implementation(projects.core.security) implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 951fdb7e..39daa741 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -28,7 +28,6 @@ dependencies { implementation(projects.core.util) implementation(projects.core.ui) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.feature.item.core) implementation(projects.feature.item.create) implementation(projects.feature.totp) diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index 2cd20f65..19dcda2f 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(projects.rust) implementation(projects.core.identity) - implementation(projects.core.security) implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.feature.item.create) From f121b7f8406e26aefdd3d87968e9ecaba19f7796 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 5 Jun 2026 15:52:30 +0200 Subject: [PATCH 06/27] feat(settings): implement autofill switch --- feature/settings/build.gradle.kts | 2 + .../settings/presentation/SettingsContent.kt | 2 +- .../settings/presentation/SettingsEvent.kt | 6 +++ .../settings/presentation/SettingsScreen.kt | 28 +++++++++- .../settings/presentation/SettingsUiState.kt | 1 + .../presentation/SettingsViewModel.kt | 51 ++++++++++++++++++- 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 210a4725..fb8934ed 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -8,4 +8,6 @@ android { dependencies { implementation(projects.core.ui) + implementation(projects.core.identity) + implementation(projects.feature.autofill) } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index e1e8734f..1ce22de9 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -47,7 +47,7 @@ internal fun SettingsContent( toggle( title = R.string.settings_autofill, icon = Icons.Default.Password, - checked = state.biometricsEnabled, + checked = state.autofillEnabled, onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, ) } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt new file mode 100644 index 00000000..b5790fe0 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.settings.presentation + +internal sealed interface SettingsEvent { + + data object OpenAutofillSelection : SettingsEvent +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index bb32a519..3059a732 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -1,16 +1,42 @@ package de.davis.keygo.feature.settings.presentation +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.ObserveAsEvents import org.koin.androidx.compose.koinViewModel @Composable fun SettingsScreen() { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + + val enableAutofillLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + viewModel.reset() + } + + val context = LocalContext.current + ObserveAsEvents(viewModel.event) { + when (it) { + is SettingsEvent.OpenAutofillSelection -> { + enableAutofillLauncher.launch( + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = "package:${context.packageName}".toUri() + } + ) + } + } + } + SettingsContent( state = state, - onEvent = {} + onEvent = viewModel::onEvent ) } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt index 8ac0abf8..867ed839 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -2,5 +2,6 @@ package de.davis.keygo.feature.settings.presentation internal data class SettingsUiState( val autofillEnabled: Boolean = false, + val biometricsAvailable: Boolean = true, val biometricsEnabled: Boolean = false, ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 45786d49..7915db75 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -1,13 +1,62 @@ package de.davis.keygo.feature.settings.presentation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @KoinViewModel -internal class SettingsViewModel : ViewModel() { +internal class SettingsViewModel( + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val accountRepository: AccountRepository, + private val autofillServiceRepository: AutofillServiceRepository, +) : ViewModel() { + + private val _event = Channel() + val event = _event.receiveAsFlow() private val _state = MutableStateFlow(SettingsUiState()) val state = _state.asStateFlow() + + init { + reset() + } + + fun reset() { + viewModelScope.launch { + val autofillEnabled = autofillServiceRepository.isEnabled() + + val biometricsAvailable = biometricAvailabilityRepository.availability() + val biometricsEnabled = + biometricsAvailable && accountRepository.getOrNull()?.biometricWrappedArk != null + + _state.update { + it.copy( + autofillEnabled = autofillEnabled, + biometricsAvailable = biometricsAvailable, + biometricsEnabled = biometricsEnabled, + ) + } + } + } + + fun onEvent(event: SettingsUiEvent) { + when (event) { + is SettingsUiEvent.SetBiometrics -> {} + + is SettingsUiEvent.SetAutofill -> { + _event.trySend(SettingsEvent.OpenAutofillSelection) + } + + SettingsUiEvent.ResetPassword -> {} + } + } } \ No newline at end of file From 05c5570e4871cd4c19ce7706405ea31ddb326157 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 6 Jun 2026 15:32:24 +0200 Subject: [PATCH 07/27] feat(core-identity): add account observable function --- .../data/repository/AccountRepositoryImpl.kt | 7 +++++-- .../domain/repository/AccountRepository.kt | 7 +++++++ .../keygo/core/identity/FakeAccountRepository.kt | 14 ++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt index ba1be8b0..7b5f7ab6 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt @@ -8,7 +8,9 @@ import de.davis.keygo.core.identity.di.annotation.AccountRegistryQualifier 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.flow.first +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single @@ -16,8 +18,9 @@ internal class AccountRepositoryImpl( @param:AccountRegistryQualifier private val dataStore: DataStore, ) : AccountRepository { + override fun observe(): Flow = dataStore.data.map { it.toDomain() } - override suspend fun getOrNull(): Account? = dataStore.data.first().toDomain() + override suspend fun getOrNull(): Account? = observe().firstOrNull() override suspend fun set(account: Account): Result = runCatching { dataStore.updateData { account.toProto() } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt index e90ba0b7..34a4b905 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.identity.domain.repository import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow /** * Persists account identity metadata and associated cryptographic key-wrapping data. @@ -18,6 +19,12 @@ import de.davis.keygo.core.util.Result */ interface AccountRepository { + /** + * Emits the currently active account (or null) and re-emits whenever the + * backing registry changes. + */ + fun observe(): Flow + /** * Returns the currently active account, or null if no account is registered. */ diff --git a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt index 31e0694e..20d84af6 100644 --- a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt +++ b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt @@ -3,22 +3,28 @@ 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.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update class FakeAccountRepository : AccountRepository { - private var account: Account? = null + private val account = MutableStateFlow(null) var setFails: Boolean = false fun seed(account: Account) { - this.account = account + this.account.update { account } } - override suspend fun getOrNull(): Account? = account + override suspend fun getOrNull(): Account? = account.value + + override fun observe(): Flow = account.asStateFlow() override suspend fun set(account: Account): Result { if (setFails) return Result.Failure(Unit) - this.account = account + this.account.update { account } return Result.Success(Unit) } } From 919bf6da99b808b6b044de7fdfb1d44f5dd4bf8a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 6 Jun 2026 22:12:28 +0200 Subject: [PATCH 08/27] feat(settings): add biometric enrollment --- .../domain/model/BiometricEnrollmentError.kt | 10 +++ .../BiometricEnrollmentAdapter.kt | 19 ++++++ .../BiometricEnrollmentAdapterImpl.kt | 64 +++++++++++++++++++ .../feature/settings/domain/model/OsState.kt | 7 ++ .../settings/presentation/SettingsEvent.kt | 1 + .../settings/presentation/SettingsScreen.kt | 32 ++++++++-- .../presentation/SettingsViewModel.kt | 60 ++++++++--------- 7 files changed, 160 insertions(+), 33 deletions(-) create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt new file mode 100644 index 00000000..5f34bc1b --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricEnrollmentError.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.identity.domain.model + +import de.davis.keygo.core.security.domain.model.BiometricAuthError + +sealed interface BiometricEnrollmentError { + data object NoActiveAccount : BiometricEnrollmentError + data object WrappingFailed : BiometricEnrollmentError + data object PersistenceFailed : BiometricEnrollmentError + data class BiometricFailed(val error: BiometricAuthError) : BiometricEnrollmentError +} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt new file mode 100644 index 00000000..c050ff0f --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapter.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.core.identity.presentation + +import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError +import de.davis.keygo.core.security.domain.model.BiometricPolicy +import de.davis.keygo.core.security.presentation.BiometricCryptoController +import de.davis.keygo.core.util.Result + +interface BiometricEnrollmentAdapter { + + suspend fun BiometricCryptoController.requestEnableBiometric( + policy: BiometricPolicy = BiometricPolicy.Default + ): Result + + suspend fun disableBiometric(): Result +} + +inline fun BiometricEnrollmentAdapter.useEnrollmentAdapter( + block: BiometricEnrollmentAdapter.() -> Result, +): Result = with(this) { block() } \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt new file mode 100644 index 00000000..cf97e04e --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricEnrollmentAdapterImpl.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.core.identity.presentation + +import androidx.compose.runtime.Composable +import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.model.BiometricPolicy +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.presentation.BiometricCryptoController +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import org.koin.compose.koinInject +import org.koin.core.annotation.Single +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +@Single +internal class BiometricEnrollmentAdapterImpl( + private val accountRepository: AccountRepository, + private val session: Session, +) : BiometricEnrollmentAdapter { + + override suspend fun BiometricCryptoController.requestEnableBiometric( + policy: BiometricPolicy + ): Result = resultBinding { + val account = accountRepository.getOrNull() + .asResult(BiometricEnrollmentError.NoActiveAccount).bind() + + val cipher = requestCipher(KeyId.BiometricVaultKek, CryptographicMode.Wrap, policy) + .bind { BiometricEnrollmentError.BiometricFailed(it) } + + + val wrapped = wrapArk(session.ark, cipher) + .asResult(BiometricEnrollmentError.WrappingFailed).bind() + + accountRepository.set(account.copy(biometricWrappedArk = wrapped)).bind { + BiometricEnrollmentError.PersistenceFailed + } + } + + override suspend fun disableBiometric(): Result = + resultBinding { + val account = accountRepository.getOrNull() + .asResult(BiometricEnrollmentError.NoActiveAccount).bind() + + accountRepository.set(account.copy(biometricWrappedArk = null)) + .bind { BiometricEnrollmentError.PersistenceFailed } + } + + private fun wrapArk(ark: ByteArray, cipher: Cipher): BiometricWrappedArk? = runCatching { + BiometricWrappedArk( + key = cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")), + keyIV = cipher.iv, + ) + }.getOrNull() +} + +@Composable +fun rememberBiometricEnrollmentAdapter(): BiometricEnrollmentAdapter { + return koinInject() +} \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt new file mode 100644 index 00000000..2228b73f --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.settings.domain.model + +/** Snapshot of OS-owned settings that can only be polled, not observed. */ +data class OsState( + val autofillEnabled: Boolean = false, + val biometricsAvailable: Boolean = true, +) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index b5790fe0..f0d759f4 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -3,4 +3,5 @@ package de.davis.keygo.feature.settings.presentation internal sealed interface SettingsEvent { data object OpenAutofillSelection : SettingsEvent + data class EnableBiometric(val enable: Boolean) : SettingsEvent } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index 3059a732..6853fda8 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -8,7 +8,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.identity.presentation.rememberBiometricEnrollmentAdapter +import de.davis.keygo.core.identity.presentation.useEnrollmentAdapter +import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.util.presentation.ObserveAsEvents import org.koin.androidx.compose.koinViewModel @@ -17,21 +21,41 @@ fun SettingsScreen() { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val biometricController = rememberBiometricCryptoController() + val enrollmentAdapter = rememberBiometricEnrollmentAdapter() + val enableAutofillLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - viewModel.reset() - } + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + + // OS-owned state (autofill / biometric availability) can change while the user is + // in a system screen; re-read it whenever we come back to the foreground. + LifecycleResumeEffect(Unit) { + viewModel.refreshSystemState() + onPauseOrDispose {} + } val context = LocalContext.current ObserveAsEvents(viewModel.event) { when (it) { - is SettingsEvent.OpenAutofillSelection -> { + SettingsEvent.OpenAutofillSelection -> { enableAutofillLauncher.launch( Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { data = "package:${context.packageName}".toUri() } ) } + + is SettingsEvent.EnableBiometric -> { + when { + it.enable -> { + enrollmentAdapter.useEnrollmentAdapter { + biometricController.requestEnableBiometric() + } + } + + else -> enrollmentAdapter.disableBiometric() + } + } } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 7915db75..37fd11cc 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -5,58 +5,60 @@ import androidx.lifecycle.viewModelScope import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import de.davis.keygo.feature.settings.domain.model.OsState import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class SettingsViewModel( private val biometricAvailabilityRepository: BiometricAvailabilityRepository, - private val accountRepository: AccountRepository, private val autofillServiceRepository: AutofillServiceRepository, + accountRepository: AccountRepository, ) : ViewModel() { private val _event = Channel() val event = _event.receiveAsFlow() - private val _state = MutableStateFlow(SettingsUiState()) - val state = _state.asStateFlow() + // OS-owned state (autofill / biometric availability) has no observable stream of + // its own; we snapshot it on lifecycle resume via refreshSystemState(). + private val osState = MutableStateFlow(OsState()) - init { - reset() - } + val state = combine( + accountRepository.observe(), + osState, + ) { account, os -> + SettingsUiState( + autofillEnabled = os.autofillEnabled, + biometricsAvailable = os.biometricsAvailable, + biometricsEnabled = os.biometricsAvailable && account?.biometricWrappedArk != null, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SettingsUiState(), + ) - fun reset() { - viewModelScope.launch { - val autofillEnabled = autofillServiceRepository.isEnabled() - - val biometricsAvailable = biometricAvailabilityRepository.availability() - val biometricsEnabled = - biometricsAvailable && accountRepository.getOrNull()?.biometricWrappedArk != null - - _state.update { - it.copy( - autofillEnabled = autofillEnabled, - biometricsAvailable = biometricsAvailable, - biometricsEnabled = biometricsEnabled, - ) - } + fun refreshSystemState() { + osState.update { + OsState( + autofillEnabled = autofillServiceRepository.isEnabled(), + biometricsAvailable = biometricAvailabilityRepository.availability(), + ) } } fun onEvent(event: SettingsUiEvent) { when (event) { - is SettingsUiEvent.SetBiometrics -> {} - - is SettingsUiEvent.SetAutofill -> { - _event.trySend(SettingsEvent.OpenAutofillSelection) - } + is SettingsUiEvent.SetBiometrics -> _event.trySend(SettingsEvent.EnableBiometric(event.enabled)) + is SettingsUiEvent.SetAutofill -> _event.trySend(SettingsEvent.OpenAutofillSelection) SettingsUiEvent.ResetPassword -> {} } } -} \ No newline at end of file +} From a725ea6513a8ff7c15dcde91191f01d5b6bfa595 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 7 Jun 2026 00:54:19 +0200 Subject: [PATCH 09/27] fix(autofill): disable autofill --- .../keygo/feature/settings/presentation/SettingsViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 37fd11cc..e85aaf64 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -56,7 +56,10 @@ internal class SettingsViewModel( fun onEvent(event: SettingsUiEvent) { when (event) { is SettingsUiEvent.SetBiometrics -> _event.trySend(SettingsEvent.EnableBiometric(event.enabled)) - is SettingsUiEvent.SetAutofill -> _event.trySend(SettingsEvent.OpenAutofillSelection) + is SettingsUiEvent.SetAutofill -> when { + event.enabledRequest -> autofillServiceRepository.disable() + else -> _event.trySend(SettingsEvent.OpenAutofillSelection) + } SettingsUiEvent.ResetPassword -> {} } From c1abdef572bfe51e0df79e2ab1c5f78a2d798136 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 00:10:22 +0200 Subject: [PATCH 10/27] fix(settings): add 3rd party section --- app/build.gradle.kts | 4 ++++ .../de/davis/keygo/app/presentation/MainActivity.kt | 4 +++- build.gradle.kts | 1 + .../feature/settings/presentation/SettingsContent.kt | 10 ++++++++++ .../feature/settings/presentation/SettingsEvent.kt | 1 + .../feature/settings/presentation/SettingsScreen.kt | 4 +++- .../feature/settings/presentation/SettingsUiEvent.kt | 1 + .../feature/settings/presentation/SettingsViewModel.kt | 1 + feature/settings/src/main/res/values/strings.xml | 2 ++ gradle/libs.versions.toml | 4 ++++ 10 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e2bee2e..37b49684 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,8 @@ plugins { alias(libs.plugins.koin.compiler) alias(libs.plugins.git.semantic.versioning) alias(libs.plugins.google.protobuf) + + alias(libs.plugins.mikepenz.aboutlibraries) } versioning { @@ -97,6 +99,8 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + implementation(libs.aboutlibraries.compose.m3) + implementation(projects.rust) implementation(projects.core.item) implementation(projects.core.identity) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 597f7562..75cce3c3 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -156,7 +156,9 @@ private fun App() { } composable { - SettingsScreen() + SettingsScreen( + showLibraries = {} + ) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 4b918167..2a6973f7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,4 +10,5 @@ plugins { alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.protobuf) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.mikepenz.aboutlibraries) apply false } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index 1ce22de9..7ac8c06b 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.settings.presentation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Password @@ -51,6 +52,15 @@ internal fun SettingsContent( onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, ) } + + section(title = R.string.settings_about) { + action( + title = R.string.settings_3rd_party_licenses, + icon = Icons.Default.Code, + isNavigation = true, + onClick = { onEvent(SettingsUiEvent.LibrariesClicked) }, + ) + } } } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index f0d759f4..cfc9c218 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.settings.presentation internal sealed interface SettingsEvent { + data object NavigateToLibraries : SettingsEvent data object OpenAutofillSelection : SettingsEvent data class EnableBiometric(val enable: Boolean) : SettingsEvent } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index 6853fda8..b638c63e 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -17,7 +17,7 @@ import de.davis.keygo.core.util.presentation.ObserveAsEvents import org.koin.androidx.compose.koinViewModel @Composable -fun SettingsScreen() { +fun SettingsScreen(showLibraries: () -> Unit) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -37,6 +37,8 @@ fun SettingsScreen() { val context = LocalContext.current ObserveAsEvents(viewModel.event) { when (it) { + SettingsEvent.NavigateToLibraries -> showLibraries() + SettingsEvent.OpenAutofillSelection -> { enableAutofillLauncher.launch( Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt index 3f5e64ed..af68ee67 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -4,4 +4,5 @@ internal sealed interface SettingsUiEvent { data class SetBiometrics(val enabled: Boolean) : SettingsUiEvent data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent data object ResetPassword : SettingsUiEvent + data object LibrariesClicked : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index e85aaf64..1bbea73e 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -62,6 +62,7 @@ internal class SettingsViewModel( } SettingsUiEvent.ResetPassword -> {} + SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) } } } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index b3b4f1d7..a1f15a70 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -4,4 +4,6 @@ Use Biometrics Reset Password Autofill + About + 3rd Party Licenses diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ac15f4b..ab0391e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ nbvcxz = "1.5.1" passGen = "0.1.0-beta" robolectric = "4.16" emvnfccard = "3.1.0" +aboutlibraries = "14.2.1" [libraries] google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } @@ -110,6 +111,8 @@ gms-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-se zxing-barcode-scanning = { group = "com.google.zxing", name = "core", version.ref = "zxingBarcodeScanning" } devnied-emvnfccard = { group = "com.github.devnied.emvnfccard", name = "library", version.ref = "emvnfccard" } +aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } + android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } koin-gradlePlugin = { group = "io.insert-koin", name = "koin-compiler-gradle-plugin", version.ref = "koin-plugin" } @@ -133,6 +136,7 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } git-semantic-versioning = { id = "io.github.offrange.git-semantic-versioning", version.ref = "semver" } android-library = { id = "com.android.library", version.ref = "agp" } +mikepenz-aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlibraries" } keygo-android-compose = { id = "keygo.android.compose" } keygo-android-library = { id = "keygo.android.library" } From d9a85a06fcadba852ea64516177bc382121073b8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 00:10:56 +0200 Subject: [PATCH 11/27] fix(settings): add version section --- .../repository/AppVersionRepositoryImpl.kt | 20 +++++++++++++++++++ .../domain/repository/AppVersionRepository.kt | 6 ++++++ .../settings/presentation/SettingsContent.kt | 7 +++++++ .../settings/presentation/SettingsUiState.kt | 1 + .../presentation/SettingsViewModel.kt | 7 ++++++- .../presentation/component/SettingsList.kt | 1 - .../settings/src/main/res/values/strings.xml | 2 ++ 7 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt new file mode 100644 index 00000000..bc8f0b05 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/data/repository/AppVersionRepositoryImpl.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.settings.data.repository + +import android.content.Context +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository +import org.koin.core.annotation.Single + +@Single +internal class AppVersionRepositoryImpl( + private val applicationContext: Context, +) : AppVersionRepository { + + private val packageInfo by lazy { + applicationContext.packageManager + .getPackageInfo(applicationContext.packageName, 0) + } + + override val versionName: String by lazy { + packageInfo.versionName.orEmpty() + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt new file mode 100644 index 00000000..1b0df992 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/repository/AppVersionRepository.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.settings.domain.repository + +interface AppVersionRepository { + + val versionName: String +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index 7ac8c06b..8b984b5e 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Update import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -60,6 +61,12 @@ internal fun SettingsContent( isNavigation = true, onClick = { onEvent(SettingsUiEvent.LibrariesClicked) }, ) + + value( + title = R.string.settings_version, + value = state.version, + icon = Icons.Default.Update, + ) } } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt index 867ed839..ffa81887 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -4,4 +4,5 @@ internal data class SettingsUiState( val autofillEnabled: Boolean = false, val biometricsAvailable: Boolean = true, val biometricsEnabled: Boolean = false, + val version: String = "2.0.0", ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 1bbea73e..89f5f8e0 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository import de.davis.keygo.feature.settings.domain.model.OsState +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -20,8 +21,11 @@ internal class SettingsViewModel( private val biometricAvailabilityRepository: BiometricAvailabilityRepository, private val autofillServiceRepository: AutofillServiceRepository, accountRepository: AccountRepository, + appVersionRepository: AppVersionRepository, ) : ViewModel() { + private val versionName = appVersionRepository.versionName + private val _event = Channel() val event = _event.receiveAsFlow() @@ -37,11 +41,12 @@ internal class SettingsViewModel( autofillEnabled = os.autofillEnabled, biometricsAvailable = os.biometricsAvailable, biometricsEnabled = os.biometricsAvailable && account?.biometricWrappedArk != null, + version = versionName, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = SettingsUiState(), + initialValue = SettingsUiState(version = versionName), ) fun refreshSystemState() { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index 1054b3da..d8365424 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -144,7 +144,6 @@ private fun SettingsEntryRow( is SettingsEntry.Value -> SegmentedListItem( onClick = entry.onClick ?: {}, - enabled = entry.onClick != null, shapes = shapes, colors = colors, leadingContent = leadingContent, diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index a1f15a70..4c2c4c34 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -4,6 +4,8 @@ Use Biometrics Reset Password Autofill + About 3rd Party Licenses + Version From b59b2f4f55c3b3dc91cd1d28a7f92f0f71df3829 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 11:46:21 +0200 Subject: [PATCH 12/27] feat(settings): add report issues --- .../feature/settings/presentation/SettingsContent.kt | 10 ++++++++++ .../feature/settings/presentation/SettingsEvent.kt | 1 + .../feature/settings/presentation/SettingsScreen.kt | 8 +++++++- .../feature/settings/presentation/SettingsUiEvent.kt | 1 + .../feature/settings/presentation/SettingsViewModel.kt | 1 + .../settings/presentation/component/SettingsDsl.kt | 4 ++++ .../settings/presentation/component/SettingsEntry.kt | 3 +++ .../settings/presentation/component/SettingsList.kt | 3 +-- feature/settings/src/main/res/values/strings.xml | 1 + 9 files changed, 29 insertions(+), 3 deletions(-) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index 8b984b5e..e4053e63 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -3,6 +3,8 @@ package de.davis.keygo.feature.settings.presentation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.LockReset @@ -62,6 +64,14 @@ internal fun SettingsContent( onClick = { onEvent(SettingsUiEvent.LibrariesClicked) }, ) + action( + title = R.string.settings_report_issue, + icon = Icons.Default.BugReport, + navigationIcon = Icons.AutoMirrored.Default.OpenInNew, + isNavigation = true, + onClick = { onEvent(SettingsUiEvent.ReportIssue) } + ) + value( title = R.string.settings_version, value = state.version, diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index cfc9c218..4c7273e2 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -5,4 +5,5 @@ internal sealed interface SettingsEvent { data object NavigateToLibraries : SettingsEvent data object OpenAutofillSelection : SettingsEvent data class EnableBiometric(val enable: Boolean) : SettingsEvent + data object ReportIssue : SettingsEvent } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index b638c63e..ccba684c 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -7,6 +7,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.core.net.toUri import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -34,6 +35,7 @@ fun SettingsScreen(showLibraries: () -> Unit) { onPauseOrDispose {} } + val urlHandler = LocalUriHandler.current val context = LocalContext.current ObserveAsEvents(viewModel.event) { when (it) { @@ -58,6 +60,8 @@ fun SettingsScreen(showLibraries: () -> Unit) { else -> enrollmentAdapter.disableBiometric() } } + + SettingsEvent.ReportIssue -> urlHandler.openUri(ISSUES_URL) } } @@ -65,4 +69,6 @@ fun SettingsScreen(showLibraries: () -> Unit) { state = state, onEvent = viewModel::onEvent ) -} \ No newline at end of file +} + +private const val ISSUES_URL = "https://github.com/OffRange/KeyGo/issues/new" \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt index af68ee67..009d6b43 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -5,4 +5,5 @@ internal sealed interface SettingsUiEvent { data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent data object ResetPassword : SettingsUiEvent data object LibrariesClicked : SettingsUiEvent + data object ReportIssue : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 89f5f8e0..675e691c 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -68,6 +68,7 @@ internal class SettingsViewModel( SettingsUiEvent.ResetPassword -> {} SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) + SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) } } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt index 7e139787..dfa4ca92 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -1,6 +1,8 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.ui.graphics.vector.ImageVector @DslMarker @@ -41,6 +43,7 @@ internal class SectionScope { @StringRes title: Int, onClick: () -> Unit, icon: ImageVector? = null, + navigationIcon: ImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, @StringRes supporting: Int? = null, isNavigation: Boolean = false, ) { @@ -48,6 +51,7 @@ internal class SectionScope { title = title, icon = icon, supporting = supporting, + navigationIcon = navigationIcon, isNavigation = isNavigation, onClick = onClick, ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt index 6de73adc..e4f48c41 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -1,6 +1,8 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.ui.graphics.vector.ImageVector internal sealed interface SettingsEntry { @@ -23,6 +25,7 @@ internal sealed interface SettingsEntry { @param:StringRes override val title: Int, override val icon: ImageVector? = null, @param:StringRes override val supporting: Int? = null, + val navigationIcon: ImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, val isNavigation: Boolean = false, val onClick: () -> Unit, ) : SettingsEntry diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index d8365424..72abf1a6 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -134,7 +133,7 @@ private fun SettingsEntryRow( trailingContent = if (entry.isNavigation) { { Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + imageVector = entry.navigationIcon, contentDescription = null, ) } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 4c2c4c34..0ab2f357 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Autofill About + Report an issue 3rd Party Licenses Version From b860f3c3dca4792464b1d23aae732fe6072a3a2d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 15:25:03 +0200 Subject: [PATCH 13/27] feat(libraries): implement 3rd party licenses --- .../keygo/app/presentation/MainActivity.kt | 22 ++++++++++++++++++- .../presentation/model/RouteDestination.kt | 6 +++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 75cce3c3..23420352 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -3,6 +3,8 @@ package de.davis.keygo.app.presentation import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -13,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Wallpapers import androidx.fragment.app.FragmentActivity @@ -24,6 +27,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import androidx.navigation.navigation +import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import de.davis.keygo.app.presentation.component.KeyGoNavigationWrapper import de.davis.keygo.core.presentation.model.RouteDestination import de.davis.keygo.core.ui.theme.KeyGoTheme @@ -157,7 +162,22 @@ private fun App() { composable { SettingsScreen( - showLibraries = {} + showLibraries = { + navController.navigate(RouteDestination.Libraries) + } + ) + } + } + + composable { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + val libs by produceLibraries() + LibrariesContainer( + libraries = libs, + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding ) } } diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt index ae250fb1..e431e9f1 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt @@ -36,4 +36,10 @@ sealed interface RouteDestination { override val graphDest: RouteDestination get() = Settings } + + @Serializable + data object Libraries : RouteDestination { + override val graphDest: RouteDestination + get() = Libraries + } } \ No newline at end of file From 61a6b5069d3cd4490a2ef626b24bfa42e955c2f5 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 17:54:09 +0200 Subject: [PATCH 14/27] feat(identity): add ChangePasswordUseCase Co-Authored-By: Claude Opus 4.8 --- .../domain/model/ChangePasswordError.kt | 18 ++ .../identity/domain/model/Reauthentication.kt | 16 ++ .../domain/usecase/ChangePasswordUseCase.kt | 89 ++++++++++ .../usecase/ChangePasswordUseCaseTest.kt | 167 ++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt create mode 100644 core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt create mode 100644 core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt new file mode 100644 index 00000000..b542fb06 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.identity.domain.model + +sealed interface ChangePasswordError { + + data object ActiveAccountNotFound : ChangePasswordError + + /** The supplied current password did not unwrap the stored ARK. */ + data object IncorrectPassword : ChangePasswordError + + /** Biometric proof supplied but the account has no biometric-wrapped ARK enrolled. */ + data object BiometricNotEnrolled : ChangePasswordError + + data object KeyDerivationFailed : ChangePasswordError + + data object WrappingFailed : ChangePasswordError + + data object PersistenceFailed : ChangePasswordError +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt new file mode 100644 index 00000000..102acbb3 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.core.identity.domain.model + +/** + * Proof of identity supplied when changing the master password. + * + * The password branch is resolved entirely in the domain ([ChangePasswordUseCase] derives the KEK + * and unwraps the ARK). The biometric branch's ARK is recovered in the presentation layer via + * `BiometricCryptoController.requestUnwrap` (an Android Keystore operation) and handed in here as + * raw bytes; both the caller and the use case zero the array after use. + */ +sealed interface Reauthentication { + + data class Password(val currentPassword: String) : Reauthentication + + class Biometric(val recoveredArk: ByteArray) : Reauthentication +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt new file mode 100644 index 00000000..46fa6dd0 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -0,0 +1,89 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.identity.domain.model.ChangePasswordError +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.rust.derive.KeyDeriver +import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult +import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import org.koin.core.annotation.Single + +/** + * Changes the master password of the active account. + * + * The user has already proven identity ([reauthentication]); the use case recovers the ARK from + * that proof and re-wraps the SAME ARK under [newPassword] with a freshly generated salt. Only the + * password-wrapped ARK is rewritten: the ARK value, the biometric-wrapped ARK, vault keys, item + * keys, and all ciphertext are unchanged, so biometric unlock keeps working without re-enrollment. + */ +@Single +class ChangePasswordUseCase( + private val accountRepository: AccountRepository, + private val keyDeriver: KeyDeriver, + private val keyWrapper: KeyWrapper, +) { + + suspend operator fun invoke( + reauthentication: Reauthentication, + newPassword: String, + ): Result = resultBinding { + val account = accountRepository.getOrNull() + ?: return Result.Failure(ChangePasswordError.ActiveAccountNotFound) + + val ark = when (reauthentication) { + is Reauthentication.Password -> { + val kek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = reauthentication.currentPassword, + salt = account.passwordWrappedArk.salt, + ).bind { ChangePasswordError.KeyDerivationFailed } + + keyWrapper.unwrapAccountRootKeyWithResult( + kek = kek, + wrapped = WrappedKeyBlob( + ciphertext = account.passwordWrappedArk.key, + nonce = account.passwordWrappedArk.keyIV, + ), + userId = account.id, + ).bind { ChangePasswordError.IncorrectPassword } + } + + is Reauthentication.Biometric -> { + account.biometricWrappedArk + ?: return Result.Failure(ChangePasswordError.BiometricNotEnrolled) + reauthentication.recoveredArk + } + } + + val newSalt = keyDeriver.generateSalt() + val newKek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = newPassword, + salt = newSalt, + ).bind { ChangePasswordError.KeyDerivationFailed } + + // Wrap a copy: `ark` is zeroed below, and the wrapper must not retain a live reference + // to key material we are about to scrub. + val rewrapped = keyWrapper.wrapAccountRootKeyWithResult( + kek = newKek, + ark = ark.copyOf(), + userId = account.id, + ).bind { ChangePasswordError.WrappingFailed } + + accountRepository.set( + account.copy( + passwordWrappedArk = PasswordWrappedArk( + key = rewrapped.ciphertext, + keyIV = rewrapped.nonce, + salt = newSalt, + ), + ), + ).bind { ChangePasswordError.PersistenceFailed } + + ark.fill(0) + } +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt new file mode 100644 index 00000000..6a0d999d --- /dev/null +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt @@ -0,0 +1,167 @@ +package de.davis.keygo.core.identity.domain.usecase + +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk +import de.davis.keygo.core.identity.domain.model.ChangePasswordError +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChangePasswordUseCaseTest { + + private val accountRepository = FakeAccountRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() + + private val useCase = ChangePasswordUseCase( + accountRepository = accountRepository, + keyDeriver = keyDeriver, + keyWrapper = keyWrapper, + ) + + private val accountId = UUID.randomUUID() + private val ark = ByteArray(32) { (it + 1).toByte() } + + private fun seedAccount( + password: String, + withBiometric: Boolean = false, + ): Account { + val salt = keyDeriver.generateSalt() + val kek = keyDeriver.deriveRootKekFromPassword(password, salt) + // Wrap a copy so the test's `ark` field stays a stable expected value: the use case + // recovers the wrapped ARK and zeroes it, and FakeKeyWrapper retains it by reference. + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark.copyOf(), accountId) + val account = Account( + id = accountId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = wrapped.ciphertext, + keyIV = wrapped.nonce, + salt = salt, + ), + biometricWrappedArk = if (withBiometric) { + BiometricWrappedArk(key = ByteArray(48) { it.toByte() }, keyIV = ByteArray(12) { it.toByte() }) + } else null, + ) + accountRepository.seed(account) + return account + } + + /** Unwraps the stored password-wrapped ARK with [password]; returns null if it doesn't unwrap. */ + private suspend fun unwrapStoredArkWith(password: String): ByteArray? { + val stored = accountRepository.getOrNull()!!.passwordWrappedArk + val kek = keyDeriver.deriveRootKekFromPassword(password, stored.salt) + return runCatching { + keyWrapper.unwrapAccountRootKey( + kek = kek, + wrapped = WrappedKeyBlob(ciphertext = stored.key, nonce = stored.keyIV), + userId = accountId, + ) + }.getOrNull() + } + + @Test + fun `returns ActiveAccountNotFound when no account is registered`() = runTest { + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.ActiveAccountNotFound, result.error) + } + + @Test + fun `returns IncorrectPassword when current password is wrong`() = runTest { + seedAccount("old") + + val result = useCase(Reauthentication.Password("wrong"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.IncorrectPassword, result.error) + } + + @Test + fun `password path re-wraps ARK so new password unwraps and old fails`() = runTest { + seedAccount("old") + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isSuccess()) + assertContentEquals(ark, unwrapStoredArkWith("new")) + assertEquals(null, unwrapStoredArkWith("old")) + } + + @Test + fun `password change rotates the salt`() = runTest { + val before = seedAccount("old").passwordWrappedArk.salt.copyOf() + + useCase(Reauthentication.Password("old"), "new") + + val after = accountRepository.getOrNull()!!.passwordWrappedArk.salt + assertFalse(before.contentEquals(after)) + } + + @Test + fun `password change leaves the biometric-wrapped ARK untouched`() = runTest { + val before = seedAccount("old", withBiometric = true).biometricWrappedArk!! + + useCase(Reauthentication.Password("old"), "new") + + val after = accountRepository.getOrNull()!!.biometricWrappedArk!! + assertContentEquals(before.key, after.key) + assertContentEquals(before.keyIV, after.keyIV) + } + + @Test + fun `biometric path re-wraps the supplied ARK under the new password`() = runTest { + seedAccount("old", withBiometric = true) + + val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + + assertTrue(result.isSuccess()) + assertContentEquals(ark, unwrapStoredArkWith("new")) + } + + @Test + fun `returns BiometricNotEnrolled when biometric proof given but none enrolled`() = runTest { + seedAccount("old", withBiometric = false) + + val result = useCase(Reauthentication.Biometric(ark.copyOf()), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.BiometricNotEnrolled, result.error) + } + + @Test + fun `returns KeyDerivationFailed when derivation fails`() = runTest { + seedAccount("old") + keyDeriver.failDerivation = true + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.KeyDerivationFailed, result.error) + } + + @Test + fun `returns PersistenceFailed when the account cannot be saved`() = runTest { + seedAccount("old") + accountRepository.setFails = true + + val result = useCase(Reauthentication.Password("old"), "new") + + assertTrue(result.isFailure()) + assertEquals(ChangePasswordError.PersistenceFailed, result.error) + } +} From 63fe611777803d59d76be2305260b20890705dcf Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 18:00:07 +0200 Subject: [PATCH 15/27] fix(identity): scrub ARK without leaving a copy; make FakeKeyWrapper faithful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangePasswordUseCase now passes the recovered ARK directly to the wrapper and scrubs that single array, instead of wrapping a copy and scrubbing only the original — the copy variant left an un-zeroed ARK lingering in the heap. Root cause: FakeKeyWrapper aliased the key arrays it was given (stored and returned the same reference), unlike the real UniFFI wrapper which copies key material across the FFI boundary and returns fresh arrays. Make the fake's wrap/unwrap defensively copy so it matches real behavior and callers can scrub recovered keys safely. UnlockWithPasswordUseCaseTest relied on that aliasing via a ByteArray assertEquals (reference equality); switch it to assertContentEquals. Co-Authored-By: Claude Opus 4.8 --- .../identity/domain/usecase/ChangePasswordUseCase.kt | 4 +--- .../identity/domain/usecase/ChangePasswordUseCaseTest.kt | 4 +--- .../domain/usecase/UnlockWithPasswordUseCaseTest.kt | 3 ++- .../kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt | 9 +++++++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt index 46fa6dd0..0a318e3d 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -66,11 +66,9 @@ class ChangePasswordUseCase( salt = newSalt, ).bind { ChangePasswordError.KeyDerivationFailed } - // Wrap a copy: `ark` is zeroed below, and the wrapper must not retain a live reference - // to key material we are about to scrub. val rewrapped = keyWrapper.wrapAccountRootKeyWithResult( kek = newKek, - ark = ark.copyOf(), + ark = ark, userId = account.id, ).bind { ChangePasswordError.WrappingFailed } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt index 6a0d999d..aff7aa89 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt @@ -41,9 +41,7 @@ class ChangePasswordUseCaseTest { ): Account { val salt = keyDeriver.generateSalt() val kek = keyDeriver.deriveRootKekFromPassword(password, salt) - // Wrap a copy so the test's `ark` field stays a stable expected value: the use case - // recovers the wrapped ARK and zeroes it, and FakeKeyWrapper retains it by reference. - val wrapped = keyWrapper.wrapAccountRootKey(kek, ark.copyOf(), accountId) + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) val account = Account( id = accountId, displayName = "Test", diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt index 134d8731..b4be406d 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.rust.FakeKeyWrapper import kotlinx.coroutines.test.runTest import java.util.UUID import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -90,6 +91,6 @@ class UnlockWithPasswordUseCaseTest { assertTrue(result.isSuccess()) assertTrue(session.startSessionCalled) - assertEquals(ark, session.ark) + assertContentEquals(ark, session.ark) } } diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt index 3a0fb6d8..afc322bf 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt @@ -79,14 +79,19 @@ class FakeKeyWrapper : KeyWrapperInterface { private fun wrap(outerKey: ByteArray, innerKey: ByteArray, id: UUID): WrappedKeyBlob { val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } val ciphertext = xorStream(innerKey, outerKey, id, nonce) - wrapRecord[Triple(outerKey.toList(), ciphertext.toList(), id)] = innerKey + // Store a copy: the real UniFFI wrapper copies the key across the FFI boundary, so callers + // are free to scrub the array they passed in. Aliasing it here would let a caller's later + // `fill(0)` zero the recorded key material. + wrapRecord[Triple(outerKey.toList(), ciphertext.toList(), id)] = innerKey.copyOf() return WrappedKeyBlob(ciphertext = ciphertext, nonce = nonce) } private fun unwrap(outerKey: ByteArray, wrapped: WrappedKeyBlob, id: UUID): ByteArray { val recorded = wrapRecord[Triple(outerKey.toList(), wrapped.ciphertext.toList(), id)] ?: throw KeyWrapException.UnwrapFailed() - return recorded + // Return a fresh array, mirroring the real wrapper: scrubbing the unwrapped key must not + // corrupt the recorded ciphertext so a subsequent unwrap still round-trips. + return recorded.copyOf() } private fun xorStream( From db54bf589d4164b88cfec3797f315b68deb3466a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 18:12:24 +0200 Subject: [PATCH 16/27] feat(settings): add ChangePasswordViewModel Add the presentation layer for the change-master-password feature: state (TextFieldState fields, field errors, password score), one-shot events, and a ViewModel that orchestrates ChangePasswordUseCase for both password and biometric reauthentication paths. Wire the missing missingDimensionStrategy and core:item / testFixtures dependencies so the settings module's unit tests resolve the flavored transitive dependency graph. Co-Authored-By: Claude Opus 4.8 --- feature/settings/build.gradle.kts | 9 ++ .../changepassword/ChangePasswordState.kt | 32 ++++ .../changepassword/ChangePasswordViewModel.kt | 133 +++++++++++++++++ .../ChangePasswordViewModelTest.kt | 140 ++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt create mode 100644 feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index fb8934ed..d960ac41 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -4,10 +4,19 @@ plugins { android { namespace = "de.davis.keygo.feature.settings" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } } dependencies { implementation(projects.core.ui) implementation(projects.core.identity) + implementation(projects.core.item) implementation(projects.feature.autofill) + + testImplementation(testFixtures(projects.core.identity)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt new file mode 100644 index 00000000..634768b9 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.foundation.text.input.TextFieldState +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.domain.model.CiphertextData + +internal data class ChangePasswordState( + val currentPassword: TextFieldState = TextFieldState(), + val newPassword: TextFieldState = TextFieldState(), + val confirmPassword: TextFieldState = TextFieldState(), + val passwordScore: PasswordScore = PasswordScore.None, + val currentPasswordError: FieldError = FieldError.None, + val newPasswordError: FieldError = FieldError.None, + val confirmPasswordError: FieldError = FieldError.None, + /** Non-null when biometric verification is offered; carries the wrapped biometric ARK. */ + val biometricCiphertext: CiphertextData? = null, + val loading: Boolean = false, +) { + val canUseBiometric: Boolean get() = biometricCiphertext != null +} + +internal sealed interface FieldError { + data object None : FieldError + data object Empty : FieldError + data object Incorrect : FieldError + data object Mismatch : FieldError +} + +internal sealed interface ChangePasswordEvent { + data object Success : ChangePasswordEvent + data object GenericError : ChangePasswordEvent +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt new file mode 100644 index 00000000..66e57ea4 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt @@ -0,0 +1,133 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.model.ChangePasswordError +import de.davis.keygo.core.identity.domain.model.Reauthentication +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.security.domain.model.CiphertextData +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@KoinViewModel +internal class ChangePasswordViewModel( + private val accountRepository: AccountRepository, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val passwordStrengthEstimator: PasswordStrengthEstimator, + private val changePassword: ChangePasswordUseCase, +) : ViewModel() { + + private val _state = MutableStateFlow(ChangePasswordState()) + val state = _state.asStateFlow() + + // Buffered (not rendezvous): Success/GenericError are emitted from a background coroutine that + // may complete before a collector subscribes; a one-shot navigation/error signal must not drop. + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + init { + resolveBiometricAvailability() + observePasswordStrength() + } + + private fun resolveBiometricAvailability() { + viewModelScope.launch { + val wrapped = accountRepository.getOrNull()?.biometricWrappedArk + val available = biometricAvailabilityRepository.availability() + if (wrapped == null || !available) return@launch + _state.update { + it.copy(biometricCiphertext = CiphertextData(bytes = wrapped.key, iv = wrapped.keyIV)) + } + } + } + + @OptIn(FlowPreview::class) + private fun observePasswordStrength() { + snapshotFlow { _state.value.newPassword.text } + .debounce(150.milliseconds) + .distinctUntilChanged() + .onEach { text -> + val score = passwordStrengthEstimator(text.toString()) + _state.update { it.copy(passwordScore = score) } + } + .launchIn(viewModelScope) + } + + /** Verify with the typed current password, then change. */ + fun submitWithPassword() { + val state = _state.value + val current = state.currentPassword.text.toString() + if (!validateNewPasswords()) return + if (current.isBlank()) { + _state.update { it.copy(currentPasswordError = FieldError.Empty) } + return + } + change(Reauthentication.Password(current)) + } + + /** Verify with biometric: [recoveredArk] was unwrapped by the screen via requestUnwrap. */ + fun submitWithBiometric(recoveredArk: ByteArray) { + if (!validateNewPasswords()) { + recoveredArk.fill(0) + return + } + change(Reauthentication.Biometric(recoveredArk)) + } + + private fun validateNewPasswords(): Boolean { + val new = _state.value.newPassword.text.toString() + val confirm = _state.value.confirmPassword.text.toString() + _state.update { + it.copy( + currentPasswordError = FieldError.None, + newPasswordError = FieldError.None, + confirmPasswordError = FieldError.None, + ) + } + if (new.isBlank()) { + _state.update { it.copy(newPasswordError = FieldError.Empty) } + return false + } + if (new != confirm) { + _state.update { it.copy(confirmPasswordError = FieldError.Mismatch) } + return false + } + return true + } + + private fun change(reauth: Reauthentication) { + _state.update { it.copy(loading = true) } + viewModelScope.launch { + changePassword(reauth, _state.value.newPassword.text.toString()) + .onSuccess { _event.trySend(ChangePasswordEvent.Success) } + .onFailure(::handleFailure) + _state.update { it.copy(loading = false) } + } + } + + private fun handleFailure(error: ChangePasswordError) { + when (error) { + ChangePasswordError.IncorrectPassword -> + _state.update { it.copy(currentPasswordError = FieldError.Incorrect) } + + else -> _event.trySend(ChangePasswordEvent.GenericError) + } + } +} diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt new file mode 100644 index 00000000..540f7c9b --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -0,0 +1,140 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ChangePasswordViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val accountRepository = FakeAccountRepository() + private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() + private val estimator = object : PasswordStrengthEstimator { + override suspend fun estimate(password: String): PasswordScore = PasswordScore.None + } + private val changePassword = ChangePasswordUseCase(accountRepository, keyDeriver, keyWrapper) + + private val accountId = UUID.randomUUID() + private val ark = ByteArray(32) { (it + 1).toByte() } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + val salt = keyDeriver.generateSalt() + val kek = keyDeriver.deriveRootKekFromPassword("old", salt) + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) + accountRepository.seed( + Account( + id = accountId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk(wrapped.ciphertext, wrapped.nonce, salt), + biometricWrappedArk = null, + ) + ) + } + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = ChangePasswordViewModel( + accountRepository = accountRepository, + biometricAvailabilityRepository = biometricAvailability, + passwordStrengthEstimator = estimator, + changePassword = changePassword, + ) + + @Test + fun `blank new password sets Empty error and does not change password`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.value.currentPassword.edit { append("old") } + + vm.submitWithPassword() + advanceUntilIdle() + + assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + } + + @Test + fun `mismatched confirmation sets Mismatch error`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.value.currentPassword.edit { append("old") } + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("different") } + + vm.submitWithPassword() + advanceUntilIdle() + + assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + } + + @Test + fun `wrong current password sets Incorrect error`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.value.currentPassword.edit { append("wrong") } + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.submitWithPassword() + advanceUntilIdle() + + assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + } + + @Test + fun `valid password change emits Success`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.value.currentPassword.edit { append("old") } + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.submitWithPassword() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @Test + fun `biometric submit with valid new passwords emits Success`() = runTest(dispatcher) { + // Account must have a biometric-wrapped ARK for the use case to accept biometric proof. + val current = accountRepository.getOrNull()!! + accountRepository.seed( + current.copy( + biometricWrappedArk = de.davis.keygo.core.identity.domain.model.BiometricWrappedArk( + key = ByteArray(48) { it.toByte() }, + keyIV = ByteArray(12) { it.toByte() }, + ) + ) + ) + val vm = viewModel() + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.submitWithBiometric(ark.copyOf()) + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } +} From 435b51107a1043c4f9b45711694c90d477c8eea9 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 18:17:21 +0200 Subject: [PATCH 17/27] feat(settings): add ChangePasswordScreen Co-Authored-By: Claude Opus 4.8 --- .../changepassword/ChangePasswordScreen.kt | 173 ++++++++++++++++++ .../settings/src/main/res/values/strings.xml | 12 ++ 2 files changed, 185 insertions(+) create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt new file mode 100644 index 00000000..238d1e40 --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt @@ -0,0 +1,173 @@ +package de.davis.keygo.feature.settings.presentation.changepassword + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.settings.R +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChangePasswordScreen(onUp: () -> Unit) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + val controller = rememberBiometricCryptoController() + val scope = rememberCoroutineScope() + + ObserveAsEvents(viewModel.event) { event -> + when (event) { + ChangePasswordEvent.Success -> onUp() + ChangePasswordEvent.GenericError -> Unit // surfaced inline; snackbar wiring is a follow-up + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.change_password_title)) }, + navigationIcon = { + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + var currentHidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.currentPassword, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.current_password)) }, + isError = state.currentPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.currentPasswordError), + textObfuscationMode = obfuscation(currentHidden), + trailingIcon = { + VisibilityButton( + isHidden = currentHidden, + onClick = { currentHidden = !currentHidden }, + ) + }, + ) + + if (state.canUseBiometric) { + OutlinedButton( + onClick = { + val ciphertext = state.biometricCiphertext ?: return@OutlinedButton + scope.launch { + when (val r = controller.requestUnwrap(KeyId.BiometricVaultKek, ciphertext)) { + is Result.Success -> viewModel.submitWithBiometric(r.success.encoded) + is Result.Failure -> Unit // user cancelled / failed + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Fingerprint, contentDescription = null) + Text( + text = stringResource(R.string.verify_with_biometric), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + var newHidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.newPassword, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.new_password)) }, + isError = state.newPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.newPasswordError), + textObfuscationMode = obfuscation(newHidden), + trailingIcon = { + VisibilityButton( + isHidden = newHidden, + onClick = { newHidden = !newHidden }, + ) + }, + ) + + StrengthIndicator(passwordScore = state.passwordScore) + + var confirmHidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.confirmPassword, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + isError = state.confirmPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.confirmPasswordError), + textObfuscationMode = obfuscation(confirmHidden), + trailingIcon = { + VisibilityButton( + isHidden = confirmHidden, + onClick = { confirmHidden = !confirmHidden }, + ) + }, + ) + + Button( + onClick = { viewModel.submitWithPassword() }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.loading, + ) { + if (state.loading) CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) + Text(stringResource(R.string.change_password_action)) + } + } + } +} + +private fun obfuscation(hidden: Boolean): TextObfuscationMode = + if (hidden) TextObfuscationMode.RevealLastTyped else TextObfuscationMode.Visible + +@Composable +private fun supportingTextFor(error: FieldError): (@Composable () -> Unit)? = when (error) { + FieldError.None -> null + FieldError.Empty -> { { Text(stringResource(R.string.password_blank)) } } + FieldError.Incorrect -> { { Text(stringResource(R.string.incorrect_password)) } } + FieldError.Mismatch -> { { Text(stringResource(R.string.passwords_do_not_match)) } } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 0ab2f357..081c37cb 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -9,4 +9,16 @@ Report an issue 3rd Party Licenses Version + + Change password + Current password + New password + Confirm new password + Change password + Verify with biometric + Password must not be blank + Incorrect password + Passwords do not match + Password changed + Could not change password From cea457efc2d6991711dc47970a5aacec40dbba41 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 22:02:26 +0200 Subject: [PATCH 18/27] feat(settings): add module-owned settings graph with change-password route Introduce SettingsGraphRoute/SettingsHomeRoute/ChangePasswordRoute and a NavGraphBuilder.settingsGraph() builder so :feature:settings owns its nested navigation graph (settings list + change-password screen). Wire the existing "Reset password" row to emit NavigateToChangePassword, which the screen forwards to onOpenChangePassword. Add the serialization plugin and navigation-compose to the module so the @Serializable routes and graph builder compile. Co-Authored-By: Claude Opus 4.8 --- feature/settings/build.gradle.kts | 3 ++ .../settings/presentation/SettingsEvent.kt | 1 + .../settings/presentation/SettingsRoutes.kt | 42 +++++++++++++++++++ .../settings/presentation/SettingsScreen.kt | 7 +++- .../presentation/SettingsViewModel.kt | 2 +- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index d960ac41..13e978ec 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -16,6 +17,8 @@ dependencies { implementation(projects.core.item) implementation(projects.feature.autofill) + implementation(libs.androidx.navigation.compose) + testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index 4c7273e2..f9b70d48 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.settings.presentation internal sealed interface SettingsEvent { data object NavigateToLibraries : SettingsEvent + data object NavigateToChangePassword : SettingsEvent data object OpenAutofillSelection : SettingsEvent data class EnableBiometric(val enable: Boolean) : SettingsEvent data object ReportIssue : SettingsEvent diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt new file mode 100644 index 00000000..83f327be --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import de.davis.keygo.core.ui.RouteDestination +import de.davis.keygo.feature.settings.presentation.changepassword.ChangePasswordScreen +import kotlinx.serialization.Serializable + +@Serializable +object SettingsGraphRoute : RouteDestination + +@Serializable +internal object SettingsHomeRoute : RouteDestination { + override val graphDest: RouteDestination get() = SettingsGraphRoute +} + +@Serializable +object ChangePasswordRoute : RouteDestination { + override val graphDest: RouteDestination get() = SettingsGraphRoute +} + +/** + * Nested settings graph: the settings list ([SettingsHomeRoute], the graph start) and the + * change-password screen ([ChangePasswordRoute]). Registered by `:app` inside `TopLevelAppGraph`, + * which keeps the Settings tab selected on both destinations. + */ +fun NavGraphBuilder.settingsGraph( + onOpenChangePassword: () -> Unit, + onShowLibraries: () -> Unit, + onUp: () -> Unit, +) = navigation(startDestination = SettingsHomeRoute) { + composable { + SettingsScreen( + showLibraries = onShowLibraries, + onOpenChangePassword = onOpenChangePassword, + ) + } + composable { + ChangePasswordScreen(onUp = onUp) + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index ccba684c..d877556b 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -18,7 +18,10 @@ import de.davis.keygo.core.util.presentation.ObserveAsEvents import org.koin.androidx.compose.koinViewModel @Composable -fun SettingsScreen(showLibraries: () -> Unit) { +fun SettingsScreen( + showLibraries: () -> Unit, + onOpenChangePassword: () -> Unit, +) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -41,6 +44,8 @@ fun SettingsScreen(showLibraries: () -> Unit) { when (it) { SettingsEvent.NavigateToLibraries -> showLibraries() + SettingsEvent.NavigateToChangePassword -> onOpenChangePassword() + SettingsEvent.OpenAutofillSelection -> { enableAutofillLauncher.launch( Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 675e691c..378b3d2f 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -66,7 +66,7 @@ internal class SettingsViewModel( else -> _event.trySend(SettingsEvent.OpenAutofillSelection) } - SettingsUiEvent.ResetPassword -> {} + SettingsUiEvent.ResetPassword -> _event.trySend(SettingsEvent.NavigateToChangePassword) SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) } From 3511ad4172e5a65ed94f8300f354d4157df281c8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 8 Jun 2026 22:05:47 +0200 Subject: [PATCH 19/27] feat(app): back Settings tab with module-owned settings graph Make the app's sealed RouteDestination extend the open core.ui.RouteDestination and drop its Settings object; retype AppDestinations.route and NavigationWrapper to the open interface so the Settings tab can be backed by the module-owned SettingsGraphRoute. Replace the single Settings composable in MainActivity with settingsGraph(...), wiring change-password navigation and up/back. Co-Authored-By: Claude Opus 4.8 --- .../keygo/app/presentation/AppDestinations.kt | 6 ++++-- .../davis/keygo/app/presentation/MainActivity.kt | 15 +++++++-------- .../presentation/component/NavigationWrapper.kt | 2 +- .../core/presentation/model/RouteDestination.kt | 11 +++-------- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt index 6d9a75db..b7a83645 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt @@ -8,9 +8,11 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import de.davis.keygo.R import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.RouteDestination as UiRouteDestination +import de.davis.keygo.feature.settings.presentation.SettingsGraphRoute enum class AppDestinations( - val route: RouteDestination, + val route: UiRouteDestination, @StringRes val label: Int, val icon: ImageVector, @StringRes val contentDescription: Int @@ -23,7 +25,7 @@ enum class AppDestinations( R.string.connectivity ), SETTINGS( - RouteDestination.Settings, + SettingsGraphRoute, R.string.settings, Icons.Default.Settings, R.string.settings diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 23420352..621908cc 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -39,7 +39,8 @@ import de.davis.keygo.dashboard.presentation.DetailType import de.davis.keygo.dashboard.presentation.dashboardGraph import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.auth.presentation.authGraph -import de.davis.keygo.feature.settings.presentation.SettingsScreen +import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute +import de.davis.keygo.feature.settings.presentation.settingsGraph import de.davis.keygo.item.dialog.SelectItemContent import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -160,13 +161,11 @@ private fun App() { dashboardGraph(listNavigator = listNavigator) } - composable { - SettingsScreen( - showLibraries = { - navController.navigate(RouteDestination.Libraries) - } - ) - } + settingsGraph( + onOpenChangePassword = { navController.navigate(ChangePasswordRoute) }, + onShowLibraries = { navController.navigate(RouteDestination.Libraries) }, + onUp = { navController.navigateUp() }, + ) } composable { diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt index 50282c64..deba4ca0 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt @@ -99,7 +99,7 @@ import de.davis.keygo.R import de.davis.keygo.app.presentation.AppDestinations import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation -import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.RouteDestination import kotlinx.coroutines.launch import kotlin.math.roundToInt import de.davis.keygo.core.ui.R as CoreUiR diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt index e431e9f1..fb3e4bb3 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt @@ -1,10 +1,11 @@ package de.davis.keygo.core.presentation.model +import de.davis.keygo.core.ui.RouteDestination as UiRouteDestination import kotlinx.serialization.Serializable -sealed interface RouteDestination { +sealed interface RouteDestination : UiRouteDestination { - val graphDest: RouteDestination + override val graphDest: RouteDestination get() = this @Serializable @@ -31,12 +32,6 @@ sealed interface RouteDestination { get() = Connectivity } - @Serializable - data object Settings : RouteDestination { - override val graphDest: RouteDestination - get() = Settings - } - @Serializable data object Libraries : RouteDestination { override val graphDest: RouteDestination From 64fee37374677eca8081792726a7f03ebe60ecc4 Mon Sep 17 00:00:00 2001 From: Davis <42292083+OffRange@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:46:13 +0200 Subject: [PATCH 20/27] feat(settings): biometric reauth for change password (#59) Co-authored-by: Claude Fable 5 --- .../domain/model/ChangePasswordError.kt | 7 - .../identity/domain/model/Reauthentication.kt | 8 - .../domain/usecase/ChangePasswordUseCase.kt | 8 - .../domain/model/BiometricAuthError.kt | 14 +- .../BiometricCryptoControllerImpl.kt | 30 +- .../BiometricAuthErrorFromTest.kt | 57 ++++ .../presentation/auth/SessionAuthState.kt | 19 +- .../settings/presentation/SettingsRoutes.kt | 5 - .../changepassword/ChangePasswordScreen.kt | 299 +++++++++++++----- .../changepassword/ChangePasswordState.kt | 7 + .../changepassword/ChangePasswordViewModel.kt | 59 +++- .../settings/src/main/res/values/strings.xml | 3 +- .../ChangePasswordViewModelTest.kt | 183 ++++++++++- 13 files changed, 555 insertions(+), 144 deletions(-) create mode 100644 core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt index b542fb06..5c673279 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/ChangePasswordError.kt @@ -3,16 +3,9 @@ package de.davis.keygo.core.identity.domain.model sealed interface ChangePasswordError { data object ActiveAccountNotFound : ChangePasswordError - - /** The supplied current password did not unwrap the stored ARK. */ data object IncorrectPassword : ChangePasswordError - - /** Biometric proof supplied but the account has no biometric-wrapped ARK enrolled. */ data object BiometricNotEnrolled : ChangePasswordError - data object KeyDerivationFailed : ChangePasswordError - data object WrappingFailed : ChangePasswordError - data object PersistenceFailed : ChangePasswordError } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt index 102acbb3..a6f5717d 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Reauthentication.kt @@ -1,13 +1,5 @@ package de.davis.keygo.core.identity.domain.model -/** - * Proof of identity supplied when changing the master password. - * - * The password branch is resolved entirely in the domain ([ChangePasswordUseCase] derives the KEK - * and unwraps the ARK). The biometric branch's ARK is recovered in the presentation layer via - * `BiometricCryptoController.requestUnwrap` (an Android Keystore operation) and handed in here as - * raw bytes; both the caller and the use case zero the array after use. - */ sealed interface Reauthentication { data class Password(val currentPassword: String) : Reauthentication diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt index 0a318e3d..ccfe4ed6 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -14,14 +14,6 @@ import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single -/** - * Changes the master password of the active account. - * - * The user has already proven identity ([reauthentication]); the use case recovers the ARK from - * that proof and re-wraps the SAME ARK under [newPassword] with a freshly generated salt. Only the - * password-wrapped ARK is rewritten: the ARK value, the biometric-wrapped ARK, vault keys, item - * keys, and all ciphertext are unchanged, so biometric unlock keeps working without re-enrollment. - */ @Single class ChangePasswordUseCase( private val accountRepository: AccountRepository, diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt index 482bc255..ab243437 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/BiometricAuthError.kt @@ -2,14 +2,14 @@ package de.davis.keygo.core.security.domain.model sealed interface BiometricAuthError { + data object Declined : BiometricAuthError + data object LockedOut : BiometricAuthError + data object Canceled : BiometricAuthError - @ConsistentCopyVisibility - data class BiometricError internal constructor( - val errorCode: Int, - val errString: String - ) : BiometricAuthError + /** Any other prompt error (timeout, vendor-specific, hardware). [errString] is user-facing. */ + data class Unknown(val errorCode: Int, val errString: String) : BiometricAuthError + /** Biometrics cannot be used at all (no hardware, none enrolled, etc.). */ data class CanNotAuthenticate(val code: Int) : BiometricAuthError - data object NoCipher : BiometricAuthError -} \ No newline at end of file +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt index 9cd03961..7adaabe5 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/presentation/BiometricCryptoControllerImpl.kt @@ -107,14 +107,7 @@ internal class BiometricCryptoControllerImpl( errorCode: Int, errString: CharSequence ) { - c.resume( - Result.Failure( - BiometricAuthError.BiometricError( - errorCode, - errString.toString() - ) - ) - ) + c.resume(Result.Failure(biometricAuthErrorFrom(errorCode, errString))) } override fun onAuthenticationFailed() { @@ -146,6 +139,27 @@ internal class BiometricCryptoControllerImpl( } } +/** + * Classify a [BiometricPrompt] error code into a semantic [BiometricAuthError] once, at the source, + * so consumers branch on meaning instead of re-interpreting raw androidx error codes. + */ +internal fun biometricAuthErrorFrom( + errorCode: Int, + errString: CharSequence, +): BiometricAuthError = when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricAuthError.Declined + + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + -> BiometricAuthError.LockedOut + + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED, + -> BiometricAuthError.Canceled + + else -> BiometricAuthError.Unknown(errorCode, errString.toString()) +} + @Composable fun rememberBiometricCryptoController(): BiometricCryptoController { val activity = LocalActivity.current as? FragmentActivity diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt new file mode 100644 index 00000000..56cf14c8 --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/presentation/BiometricAuthErrorFromTest.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.core.security.presentation + +import androidx.biometric.BiometricPrompt +import de.davis.keygo.core.security.domain.model.BiometricAuthError +import kotlin.test.Test +import kotlin.test.assertEquals + +class BiometricAuthErrorFromTest { + + @Test + fun `negative button maps to Declined`() { + assertEquals( + BiometricAuthError.Declined, + biometricAuthErrorFrom(BiometricPrompt.ERROR_NEGATIVE_BUTTON, "use password"), + ) + } + + @Test + fun `lockout maps to LockedOut`() { + assertEquals( + BiometricAuthError.LockedOut, + biometricAuthErrorFrom(BiometricPrompt.ERROR_LOCKOUT, "too many attempts"), + ) + } + + @Test + fun `permanent lockout maps to LockedOut`() { + assertEquals( + BiometricAuthError.LockedOut, + biometricAuthErrorFrom(BiometricPrompt.ERROR_LOCKOUT_PERMANENT, "locked out"), + ) + } + + @Test + fun `user cancel maps to Canceled`() { + assertEquals( + BiometricAuthError.Canceled, + biometricAuthErrorFrom(BiometricPrompt.ERROR_USER_CANCELED, "canceled"), + ) + } + + @Test + fun `system cancel maps to Canceled`() { + assertEquals( + BiometricAuthError.Canceled, + biometricAuthErrorFrom(BiometricPrompt.ERROR_CANCELED, "canceled"), + ) + } + + @Test + fun `timeout maps to Unknown carrying code and message`() { + assertEquals( + BiometricAuthError.Unknown(BiometricPrompt.ERROR_TIMEOUT, "timed out"), + biometricAuthErrorFrom(BiometricPrompt.ERROR_TIMEOUT, "timed out"), + ) + } +} diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt index 38ac891c..93dd90ca 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/auth/SessionAuthState.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.credentials.presentation.auth -import androidx.biometric.BiometricPrompt import de.davis.keygo.core.identity.domain.model.UnlockError import de.davis.keygo.core.security.domain.model.BiometricAuthError @@ -13,20 +12,14 @@ internal sealed interface SessionAuthState { internal enum class UnlockOutcome { Abort, NeedsPassword } internal fun mapUnlockError(error: UnlockError): UnlockOutcome = when (error) { - is UnlockError.BiometricFailed -> when (val biometricError = error.error) { - is BiometricAuthError.BiometricError -> when (biometricError.errorCode) { - BiometricPrompt.ERROR_USER_CANCELED, - BiometricPrompt.ERROR_CANCELED -> UnlockOutcome.Abort - - BiometricPrompt.ERROR_NEGATIVE_BUTTON, - BiometricPrompt.ERROR_LOCKOUT, - BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> UnlockOutcome.NeedsPassword - - else -> UnlockOutcome.NeedsPassword - } + is UnlockError.BiometricFailed -> when (error.error) { + BiometricAuthError.Canceled, + BiometricAuthError.NoCipher -> UnlockOutcome.Abort + BiometricAuthError.Declined, + BiometricAuthError.LockedOut, + is BiometricAuthError.Unknown, is BiometricAuthError.CanNotAuthenticate -> UnlockOutcome.NeedsPassword - BiometricAuthError.NoCipher -> UnlockOutcome.Abort } UnlockError.WrappedKeyNotFound -> UnlockOutcome.NeedsPassword diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt index 83f327be..3e88e0c9 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt @@ -20,11 +20,6 @@ object ChangePasswordRoute : RouteDestination { override val graphDest: RouteDestination get() = SettingsGraphRoute } -/** - * Nested settings graph: the settings list ([SettingsHomeRoute], the graph start) and the - * change-password screen ([ChangePasswordRoute]). Registered by `:app` inside `TopLevelAppGraph`, - * which keeps the Settings tab selected on both destinations. - */ fun NavGraphBuilder.settingsGraph( onOpenChangePassword: () -> Unit, onShowLibraries: () -> Unit, diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt index 238d1e40..2d52c41a 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt @@ -1,26 +1,33 @@ package de.davis.keygo.feature.settings.presentation.changepassword +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextObfuscationMode -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,20 +35,29 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.core.security.domain.model.BiometricPolicy +import de.davis.keygo.core.security.domain.model.BiometricString +import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.ui.components.VisibilityButton -import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString +import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager import de.davis.keygo.feature.settings.R import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChangePasswordScreen(onUp: () -> Unit) { val viewModel = koinViewModel() @@ -49,89 +65,113 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { val controller = rememberBiometricCryptoController() val scope = rememberCoroutineScope() + val snackbarManager = LocalSnackbarManager.current ObserveAsEvents(viewModel.event) { event -> when (event) { ChangePasswordEvent.Success -> onUp() - ChangePasswordEvent.GenericError -> Unit // surfaced inline; snackbar wiring is a follow-up + ChangePasswordEvent.GenericError -> snackbarManager.sendMessage( + SnackbarMessage(message = ResourceString(R.string.change_password_failed)), + ) + ChangePasswordEvent.LaunchBiometricPrompt -> { + val ciphertext = state.biometricCiphertext ?: return@ObserveAsEvents + scope.launch { + val result = controller.requestUnwrap( + keyId = KeyId.BiometricVaultKek, + ciphertextData = ciphertext, + policy = BiometricPolicy( + negativeButton = BiometricString.NegativeButton.Password, + ), + ) + viewModel.onBiometricResult(result) + } + } } } + ChangePasswordContent( + state = state, + onUp = onUp, + onSubmit = viewModel::onSubmit, + onSubmitWithPassword = viewModel::submitWithPassword, + onDismissReauthDialog = viewModel::dismissReauthDialog, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChangePasswordContent( + state: ChangePasswordState, + onUp: () -> Unit, + onSubmit: () -> Unit, + onSubmitWithPassword: () -> Unit, + onDismissReauthDialog: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), topBar = { - TopAppBar( - title = { Text(stringResource(R.string.change_password_title)) }, - navigationIcon = { - IconButton(onClick = onUp) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - ) + Column { + TopAppBar( + title = { Text(stringResource(R.string.change_password_title)) }, + navigationIcon = { + IconButton(onClick = onUp, enabled = !state.loading) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + AnimatedVisibility( + visible = state.loading && !state.showReauthDialog, + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } }, ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp, vertical = 8.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - var currentHidden by rememberSaveable { mutableStateOf(true) } - OutlinedSecureTextField( + if (!state.canUseBiometric) CurrentPasswordField( state = state.currentPassword, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.current_password)) }, - isError = state.currentPasswordError !is FieldError.None, - supportingText = supportingTextFor(state.currentPasswordError), - textObfuscationMode = obfuscation(currentHidden), - trailingIcon = { - VisibilityButton( - isHidden = currentHidden, - onClick = { currentHidden = !currentHidden }, - ) - }, + error = state.currentPasswordError, ) - if (state.canUseBiometric) { - OutlinedButton( - onClick = { - val ciphertext = state.biometricCiphertext ?: return@OutlinedButton - scope.launch { - when (val r = controller.requestUnwrap(KeyId.BiometricVaultKek, ciphertext)) { - is Result.Success -> viewModel.submitWithBiometric(r.success.encoded) - is Result.Failure -> Unit // user cancelled / failed - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Default.Fingerprint, contentDescription = null) - Text( - text = stringResource(R.string.verify_with_biometric), - modifier = Modifier.padding(start = 8.dp), - ) - } - } - var newHidden by rememberSaveable { mutableStateOf(true) } - OutlinedSecureTextField( - state = state.newPassword, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.new_password)) }, - isError = state.newPasswordError !is FieldError.None, - supportingText = supportingTextFor(state.newPasswordError), - textObfuscationMode = obfuscation(newHidden), - trailingIcon = { - VisibilityButton( - isHidden = newHidden, - onClick = { newHidden = !newHidden }, - ) - }, - ) + var forceCompact by rememberSaveable { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedSecureTextField( + state = state.newPassword, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + forceCompact = !it.isFocused + }, + label = { Text(stringResource(R.string.new_password)) }, + isError = state.newPasswordError !is FieldError.None, + supportingText = supportingTextFor(state.newPasswordError), + textObfuscationMode = obfuscation(newHidden), + trailingIcon = { + VisibilityButton( + isHidden = newHidden, + onClick = { newHidden = !newHidden }, + ) + }, + ) - StrengthIndicator(passwordScore = state.passwordScore) + StrengthIndicator( + passwordScore = state.passwordScore, + forceCompact = forceCompact + ) + } var confirmHidden by rememberSaveable { mutableStateOf(true) } OutlinedSecureTextField( @@ -150,15 +190,54 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { ) Button( - onClick = { viewModel.submitWithPassword() }, + onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = !state.loading, ) { - if (state.loading) CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) + if (state.canUseBiometric) { + Icon(Icons.Default.Fingerprint, contentDescription = null) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + } Text(stringResource(R.string.change_password_action)) } + + if (state.canUseBiometric) Text( + text = stringResource(R.string.biometric_confirm_helper), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + + if (state.showReauthDialog) { + AlertDialog( + onDismissRequest = onDismissReauthDialog, + title = { Text(stringResource(R.string.reauth_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + CurrentPasswordField( + state = state.currentPassword, + error = state.currentPasswordError, + ) + AnimatedVisibility( + visible = state.loading, + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + }, + confirmButton = { + Button(onClick = onSubmitWithPassword, enabled = !state.loading) { + Text(stringResource(R.string.change_password_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismissReauthDialog, enabled = !state.loading) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } } private fun obfuscation(hidden: Boolean): TextObfuscationMode = @@ -167,7 +246,81 @@ private fun obfuscation(hidden: Boolean): TextObfuscationMode = @Composable private fun supportingTextFor(error: FieldError): (@Composable () -> Unit)? = when (error) { FieldError.None -> null - FieldError.Empty -> { { Text(stringResource(R.string.password_blank)) } } - FieldError.Incorrect -> { { Text(stringResource(R.string.incorrect_password)) } } - FieldError.Mismatch -> { { Text(stringResource(R.string.passwords_do_not_match)) } } + FieldError.Empty -> { + { Text(stringResource(R.string.password_blank)) } + } + + FieldError.Incorrect -> { + { Text(stringResource(R.string.incorrect_password)) } + } + + FieldError.Mismatch -> { + { Text(stringResource(R.string.passwords_do_not_match)) } + } +} + +@Composable +private fun CurrentPasswordField( + state: TextFieldState, + error: FieldError, + modifier: Modifier = Modifier, +) { + var hidden by rememberSaveable { mutableStateOf(true) } + OutlinedSecureTextField( + state = state, + modifier = modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.current_password)) }, + isError = error !is FieldError.None, + supportingText = supportingTextFor(error), + textObfuscationMode = obfuscation(hidden), + trailingIcon = { + VisibilityButton(isHidden = hidden, onClick = { hidden = !hidden }) + }, + ) +} + +private class ChangePasswordStateProvider : PreviewParameterProvider { + + + private val previewBiometricCiphertext = CiphertextData(bytes = ByteArray(0), iv = ByteArray(0)) + + override val values = sequenceOf( + ChangePasswordState(), + ChangePasswordState(biometricCiphertext = previewBiometricCiphertext), + ChangePasswordState( + currentPasswordError = FieldError.Incorrect, + newPasswordError = FieldError.Empty, + confirmPasswordError = FieldError.Mismatch, + ), + ChangePasswordState(loading = true), + ChangePasswordState( + biometricCiphertext = previewBiometricCiphertext, + showReauthDialog = true, + ), + ChangePasswordState( + biometricCiphertext = previewBiometricCiphertext, + showReauthDialog = true, + loading = true, + ), + ) +} + +@Preview +@Composable +private fun ChangePasswordPreviewContainer( + @PreviewParameter(ChangePasswordStateProvider::class) state: ChangePasswordState +) { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + ChangePasswordContent( + state = state, + onUp = {}, + onSubmit = {}, + onSubmitWithPassword = {}, + onDismissReauthDialog = {}, + ) + } + } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt index 634768b9..1c541edb 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordState.kt @@ -1,9 +1,11 @@ package de.davis.keygo.feature.settings.presentation.changepassword import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.domain.model.CiphertextData +@Stable internal data class ChangePasswordState( val currentPassword: TextFieldState = TextFieldState(), val newPassword: TextFieldState = TextFieldState(), @@ -14,6 +16,8 @@ internal data class ChangePasswordState( val confirmPasswordError: FieldError = FieldError.None, /** Non-null when biometric verification is offered; carries the wrapped biometric ARK. */ val biometricCiphertext: CiphertextData? = null, + /** True while the master-password fallback dialog is shown (biometric users). */ + val showReauthDialog: Boolean = false, val loading: Boolean = false, ) { val canUseBiometric: Boolean get() = biometricCiphertext != null @@ -29,4 +33,7 @@ internal sealed interface FieldError { internal sealed interface ChangePasswordEvent { data object Success : ChangePasswordEvent data object GenericError : ChangePasswordEvent + + /** Ask the screen to launch the biometric prompt (it owns the controller). */ + data object LaunchBiometricPrompt : ChangePasswordEvent } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt index 66e57ea4..1ae7fd0a 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModel.kt @@ -8,8 +8,10 @@ import de.davis.keygo.core.identity.domain.model.Reauthentication import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import kotlinx.coroutines.FlowPreview @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import java.security.Key import kotlin.time.Duration.Companion.milliseconds @KoinViewModel @@ -53,7 +56,12 @@ internal class ChangePasswordViewModel( val available = biometricAvailabilityRepository.availability() if (wrapped == null || !available) return@launch _state.update { - it.copy(biometricCiphertext = CiphertextData(bytes = wrapped.key, iv = wrapped.keyIV)) + it.copy( + biometricCiphertext = CiphertextData( + bytes = wrapped.key, + iv = wrapped.keyIV + ) + ) } } } @@ -70,6 +78,20 @@ internal class ChangePasswordViewModel( .launchIn(viewModelScope) } + /** + * Primary "Change password" action. Validates the new passwords first so we never fire a + * biometric prompt for an invalid form, then routes to biometric (if available) or the + * typed-password path. + */ + fun onSubmit() { + if (_state.value.canUseBiometric) { + if (!validateNewPasswords()) return + _event.trySend(ChangePasswordEvent.LaunchBiometricPrompt) + return + } + submitWithPassword() + } + /** Verify with the typed current password, then change. */ fun submitWithPassword() { val state = _state.value @@ -82,8 +104,13 @@ internal class ChangePasswordViewModel( change(Reauthentication.Password(current)) } + /** Dismiss the fallback dialog and clear any stale current-password error. */ + fun dismissReauthDialog() { + _state.update { it.copy(showReauthDialog = false, currentPasswordError = FieldError.None) } + } + /** Verify with biometric: [recoveredArk] was unwrapped by the screen via requestUnwrap. */ - fun submitWithBiometric(recoveredArk: ByteArray) { + private fun submitWithBiometric(recoveredArk: ByteArray) { if (!validateNewPasswords()) { recoveredArk.fill(0) return @@ -91,6 +118,34 @@ internal class ChangePasswordViewModel( change(Reauthentication.Biometric(recoveredArk)) } + /** + * Route the outcome of the screen's biometric prompt. The screen owns the prompt (it needs an + * Activity), but interpreting the result is ours: a recovered key changes the password, while a + * declined / locked-out / failed prompt falls back to the master-password dialog. + */ + fun onBiometricResult(result: Result) { + when (result) { + is Result.Success -> { + // requestUnwrap returns a software SecretKeySpec (AES), so raw bytes always exist. + val recoveredArk = checkNotNull(result.success.encoded) + submitWithBiometric(recoveredArk) + } + + is Result.Failure -> when (result.error) { + BiometricAuthError.Declined, + BiometricAuthError.LockedOut, + BiometricAuthError.NoCipher, + is BiometricAuthError.CanNotAuthenticate, + -> _state.update { it.copy(showReauthDialog = true) } + + // Transient dismissal (back press, system cancel, timeout): leave the form as-is. + BiometricAuthError.Canceled, + is BiometricAuthError.Unknown, + -> Unit + } + } + } + private fun validateNewPasswords(): Boolean { val new = _state.value.newPassword.text.toString() val confirm = _state.value.confirmPassword.text.toString() diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 081c37cb..07d02405 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -15,7 +15,8 @@ New password Confirm new password Change password - Verify with biometric + You\'ll confirm with your fingerprint + Enter your current password Password must not be blank Incorrect password Passwords do not match diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt index 540f7c9b..70f6e147 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -2,11 +2,14 @@ package de.davis.keygo.feature.settings.presentation.changepassword import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.usecase.ChangePasswordUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.util.Result import de.davis.keygo.rust.FakeKeyDeriver import de.davis.keygo.rust.FakeKeyWrapper import kotlinx.coroutines.Dispatchers @@ -17,7 +20,9 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import java.security.Key import java.util.UUID +import javax.crypto.spec.SecretKeySpec import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -59,6 +64,20 @@ class ChangePasswordViewModelTest { @AfterTest fun tearDown() = Dispatchers.resetMain() + /** Re-seed the account with a biometric-wrapped ARK and mark hardware available. */ + private suspend fun enableBiometric() { + biometricAvailability.isAvailable = true + val current = accountRepository.getOrNull()!! + accountRepository.seed( + current.copy( + biometricWrappedArk = BiometricWrappedArk( + key = ByteArray(48) { it.toByte() }, + keyIV = ByteArray(12) { it.toByte() }, + ) + ) + ) + } + private fun viewModel() = ChangePasswordViewModel( accountRepository = accountRepository, biometricAvailabilityRepository = biometricAvailability, @@ -117,24 +136,164 @@ class ChangePasswordViewModelTest { } @Test - fun `biometric submit with valid new passwords emits Success`() = runTest(dispatcher) { - // Account must have a biometric-wrapped ARK for the use case to accept biometric proof. - val current = accountRepository.getOrNull()!! - accountRepository.seed( - current.copy( - biometricWrappedArk = de.davis.keygo.core.identity.domain.model.BiometricWrappedArk( - key = ByteArray(48) { it.toByte() }, - keyIV = ByteArray(12) { it.toByte() }, - ) - ) - ) + fun `onSubmit with biometric available and valid passwords emits LaunchBiometricPrompt`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() // let resolveBiometricAvailability() populate biometricCiphertext + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.LaunchBiometricPrompt, vm.event.first()) + } + + @Test + fun `onSubmit with biometric available and blank new password sets Empty and does not prompt`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + } + + @Test + fun `onSubmit without biometric and valid passwords emits Success`() = runTest(dispatcher) { + val vm = viewModel() // setUp seeds an account with no biometric ARK; availability defaults false + vm.state.value.currentPassword.edit { append("old") } + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @Test + fun `onSubmit with biometric available and mismatched passwords sets Mismatch and does not prompt`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.state.value.newPassword.edit { append("aaa") } + vm.state.value.confirmPassword.edit { append("bbb") } + + vm.onSubmit() + advanceUntilIdle() + + assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + } + + @Test + fun `dismissReauthDialog hides dialog and clears current password error`() = runTest(dispatcher) { + enableBiometric() val vm = viewModel() + advanceUntilIdle() + vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog vm.state.value.newPassword.edit { append("brand-new") } vm.state.value.confirmPassword.edit { append("brand-new") } + vm.state.value.currentPassword.edit { append("wrong") } + vm.submitWithPassword() + advanceUntilIdle() + // currentPasswordError is now FieldError.Incorrect — the assertion below is load-bearing + + vm.dismissReauthDialog() - vm.submitWithBiometric(ark.copyOf()) + assertEquals(false, vm.state.value.showReauthDialog) + assertEquals(FieldError.None, vm.state.value.currentPasswordError) + } + + @Test + fun `dialog confirm with wrong current password keeps dialog open with Incorrect error`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + vm.state.value.currentPassword.edit { append("wrong") } + + vm.submitWithPassword() // dialog Confirm action + advanceUntilIdle() + + assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `dialog confirm with correct current password emits Success`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + vm.state.value.currentPassword.edit { append("old") } + + vm.submitWithPassword() // dialog Confirm action advanceUntilIdle() assertEquals(ChangePasswordEvent.Success, vm.event.first()) } + + @Test + fun `onBiometricResult with a recovered key changes password and emits Success`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + val recovered: Result = + Result.Success(SecretKeySpec(ark.copyOf(), "AES")) + + vm.onBiometricResult(recovered) + advanceUntilIdle() + + assertEquals(ChangePasswordEvent.Success, vm.event.first()) + } + + @Test + fun `onBiometricResult failure opens the reauth dialog`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.NoCipher) + + vm.onBiometricResult(failure) + + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `onBiometricResult Declined opens the reauth dialog`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.Declined) + + vm.onBiometricResult(failure) + + assertEquals(true, vm.state.value.showReauthDialog) + } + + @Test + fun `onBiometricResult Canceled leaves the form untouched`() = runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + val failure: Result = Result.Failure(BiometricAuthError.Canceled) + + vm.onBiometricResult(failure) + + assertEquals(false, vm.state.value.showReauthDialog) + } } From 401873b9ee52986cfa1a33a19e389ba27d12d282 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 17:03:44 +0200 Subject: [PATCH 21/27] feat(settings): add descriptions and refactor navigation icons --- .../keygo/feature/settings/presentation/SettingsContent.kt | 5 +++-- .../feature/settings/presentation/component/SettingsDsl.kt | 6 +----- .../settings/presentation/component/SettingsEntry.kt | 5 +---- .../feature/settings/presentation/component/SettingsList.kt | 2 +- feature/settings/src/main/res/values/strings.xml | 3 +++ 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index e4053e63..6fb5386f 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -38,12 +38,14 @@ internal fun SettingsContent( action( title = R.string.settings_reset_password, icon = Icons.Default.LockReset, + supporting = R.string.settings_reset_password_description, onClick = { onEvent(SettingsUiEvent.ResetPassword) }, ) toggle( title = R.string.settings_use_biometrics, icon = Icons.Default.Fingerprint, + supporting = R.string.settings_use_biometrics_description, checked = state.biometricsEnabled, onCheckedChange = { onEvent(SettingsUiEvent.SetBiometrics(it)) }, ) @@ -51,6 +53,7 @@ internal fun SettingsContent( toggle( title = R.string.settings_autofill, icon = Icons.Default.Password, + supporting = R.string.settings_autofill_description, checked = state.autofillEnabled, onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, ) @@ -60,7 +63,6 @@ internal fun SettingsContent( action( title = R.string.settings_3rd_party_licenses, icon = Icons.Default.Code, - isNavigation = true, onClick = { onEvent(SettingsUiEvent.LibrariesClicked) }, ) @@ -68,7 +70,6 @@ internal fun SettingsContent( title = R.string.settings_report_issue, icon = Icons.Default.BugReport, navigationIcon = Icons.AutoMirrored.Default.OpenInNew, - isNavigation = true, onClick = { onEvent(SettingsUiEvent.ReportIssue) } ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt index dfa4ca92..d3daa967 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -1,8 +1,6 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.ui.graphics.vector.ImageVector @DslMarker @@ -43,16 +41,14 @@ internal class SectionScope { @StringRes title: Int, onClick: () -> Unit, icon: ImageVector? = null, - navigationIcon: ImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + navigationIcon: ImageVector? = null, @StringRes supporting: Int? = null, - isNavigation: Boolean = false, ) { entries += SettingsEntry.Action( title = title, icon = icon, supporting = supporting, navigationIcon = navigationIcon, - isNavigation = isNavigation, onClick = onClick, ) } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt index e4f48c41..e48ba4e1 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -1,8 +1,6 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.ui.graphics.vector.ImageVector internal sealed interface SettingsEntry { @@ -25,8 +23,7 @@ internal sealed interface SettingsEntry { @param:StringRes override val title: Int, override val icon: ImageVector? = null, @param:StringRes override val supporting: Int? = null, - val navigationIcon: ImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - val isNavigation: Boolean = false, + val navigationIcon: ImageVector? = null, val onClick: () -> Unit, ) : SettingsEntry diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index 72abf1a6..f4cc23c9 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -130,7 +130,7 @@ private fun SettingsEntryRow( leadingContent = leadingContent, supportingContent = supportingContent, verticalAlignment = verticalAlignment, - trailingContent = if (entry.isNavigation) { + trailingContent = if (entry.navigationIcon != null) { { Icon( imageVector = entry.navigationIcon, diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 07d02405..d7da6ac9 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -2,8 +2,11 @@ Security Use Biometrics + Unlock KeyGo with your fingerprint or face Reset Password + Change your main password that protects your data Autofill + Fill in your logins in other apps and browsers About Report an issue From 4db0e654e4dab3afc2333dafdd9530a8be12f813 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 17:08:30 +0200 Subject: [PATCH 22/27] feat(settings): add export and import data actions Add a new "Backup" section to the settings screen featuring export and import data actions. This includes updating the ViewModel, defining new UI events, and adding the required string resources. --- .../settings/presentation/SettingsContent.kt | 18 ++++++++++++++++++ .../settings/presentation/SettingsEvent.kt | 3 +++ .../settings/presentation/SettingsRoutes.kt | 4 ++++ .../settings/presentation/SettingsScreen.kt | 5 +++++ .../settings/presentation/SettingsUiEvent.kt | 4 +++- .../settings/presentation/SettingsViewModel.kt | 4 ++++ .../settings/src/main/res/values/strings.xml | 6 ++++++ 7 files changed, 43 insertions(+), 1 deletion(-) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index 6fb5386f..b06fd04a 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.SettingsBackupRestore import androidx.compose.material.icons.filled.Update import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -59,6 +61,22 @@ internal fun SettingsContent( ) } + section(title = R.string.settings_backup) { + action( + title = R.string.settings_export_data, + icon = Icons.Default.Backup, + supporting = R.string.settings_export_data_description, + onClick = { onEvent(SettingsUiEvent.ExportData) }, + ) + + action( + title = R.string.settings_import_data, + icon = Icons.Default.SettingsBackupRestore, + supporting = R.string.settings_import_data_description, + onClick = { onEvent(SettingsUiEvent.ImportData) }, + ) + } + section(title = R.string.settings_about) { action( title = R.string.settings_3rd_party_licenses, diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index f9b70d48..e9cb1b7d 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -7,4 +7,7 @@ internal sealed interface SettingsEvent { data object OpenAutofillSelection : SettingsEvent data class EnableBiometric(val enable: Boolean) : SettingsEvent data object ReportIssue : SettingsEvent + + data object ExportData : SettingsEvent + data object ImportData : SettingsEvent } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt index 3e88e0c9..b3e1f318 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt @@ -29,6 +29,10 @@ fun NavGraphBuilder.settingsGraph( SettingsScreen( showLibraries = onShowLibraries, onOpenChangePassword = onOpenChangePassword, + + // TODO: #51 + onExportDataClicked = {}, + onImportDataClicked = {} ) } composable { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index d877556b..905b8ab1 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -20,6 +20,8 @@ import org.koin.androidx.compose.koinViewModel @Composable fun SettingsScreen( showLibraries: () -> Unit, + onExportDataClicked: () -> Unit, + onImportDataClicked: () -> Unit, onOpenChangePassword: () -> Unit, ) { val viewModel = koinViewModel() @@ -67,6 +69,9 @@ fun SettingsScreen( } SettingsEvent.ReportIssue -> urlHandler.openUri(ISSUES_URL) + + SettingsEvent.ExportData -> onExportDataClicked + SettingsEvent.ImportData -> onImportDataClicked } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt index 009d6b43..a5a7930c 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -4,6 +4,8 @@ internal sealed interface SettingsUiEvent { data class SetBiometrics(val enabled: Boolean) : SettingsUiEvent data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent data object ResetPassword : SettingsUiEvent - data object LibrariesClicked : SettingsUiEvent + data object ExportData : SettingsUiEvent + data object ImportData : SettingsUiEvent data object ReportIssue : SettingsUiEvent + data object LibrariesClicked : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 378b3d2f..ec523373 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -67,6 +67,10 @@ internal class SettingsViewModel( } SettingsUiEvent.ResetPassword -> _event.trySend(SettingsEvent.NavigateToChangePassword) + + SettingsUiEvent.ExportData -> _event.trySend(SettingsEvent.ExportData) + SettingsUiEvent.ImportData -> _event.trySend(SettingsEvent.ImportData) + SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index d7da6ac9..72ac1c73 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -8,6 +8,12 @@ Autofill Fill in your logins in other apps and browsers + Backup + Export Data + Save an encrypted backup of your vaults to a file + Import Data + Restore your vaults from a backup file + About Report an issue 3rd Party Licenses From 29b259c0a0f3d6077902b4ee073ce47a2ad1fdf2 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 19:39:25 +0200 Subject: [PATCH 23/27] fix(identity): zeroize transient key material during password change Wipe the caller-supplied ARK and both derived KEKs in finally blocks so no key copy survives an early return or a failed re-wrap. Key derivation now suspends and runs on Dispatchers.Default instead of blocking the caller thread. Co-Authored-By: Claude Opus 4.8 --- .../domain/usecase/ChangePasswordUseCase.kt | 78 ++++++++++++------- .../usecase/ChangePasswordUseCaseTest.kt | 31 ++++++++ .../ChangePasswordViewModelTest.kt | 13 +++- .../de/davis/keygo/rust/derive/KeyDeriver.kt | 18 +++-- 4 files changed, 101 insertions(+), 39 deletions(-) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt index ccfe4ed6..f12b4214 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCase.kt @@ -24,6 +24,17 @@ class ChangePasswordUseCase( suspend operator fun invoke( reauthentication: Reauthentication, newPassword: String, + ): Result = try { + changePassword(reauthentication, newPassword) + } finally { + // We take ownership of the caller-supplied ARK: never leave a copy behind, + // even when an early validation bails out or re-wrapping fails part-way. + if (reauthentication is Reauthentication.Biometric) reauthentication.recoveredArk.fill(0) + } + + private suspend fun changePassword( + reauthentication: Reauthentication, + newPassword: String, ): Result = resultBinding { val account = accountRepository.getOrNull() ?: return Result.Failure(ChangePasswordError.ActiveAccountNotFound) @@ -35,14 +46,18 @@ class ChangePasswordUseCase( salt = account.passwordWrappedArk.salt, ).bind { ChangePasswordError.KeyDerivationFailed } - keyWrapper.unwrapAccountRootKeyWithResult( - kek = kek, - wrapped = WrappedKeyBlob( - ciphertext = account.passwordWrappedArk.key, - nonce = account.passwordWrappedArk.keyIV, - ), - userId = account.id, - ).bind { ChangePasswordError.IncorrectPassword } + try { + keyWrapper.unwrapAccountRootKeyWithResult( + kek = kek, + wrapped = WrappedKeyBlob( + ciphertext = account.passwordWrappedArk.key, + nonce = account.passwordWrappedArk.keyIV, + ), + userId = account.id, + ).bind { ChangePasswordError.IncorrectPassword } + } finally { + kek.fill(0) + } } is Reauthentication.Biometric -> { @@ -52,28 +67,35 @@ class ChangePasswordUseCase( } } - val newSalt = keyDeriver.generateSalt() - val newKek = keyDeriver.deriveRootKekFromPasswordWithResult( - password = newPassword, - salt = newSalt, - ).bind { ChangePasswordError.KeyDerivationFailed } + try { + val newSalt = keyDeriver.generateSalt() + val newKek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = newPassword, + salt = newSalt, + ).bind { ChangePasswordError.KeyDerivationFailed } - val rewrapped = keyWrapper.wrapAccountRootKeyWithResult( - kek = newKek, - ark = ark, - userId = account.id, - ).bind { ChangePasswordError.WrappingFailed } + val rewrapped = try { + keyWrapper.wrapAccountRootKeyWithResult( + kek = newKek, + ark = ark, + userId = account.id, + ).bind { ChangePasswordError.WrappingFailed } + } finally { + newKek.fill(0) + } - accountRepository.set( - account.copy( - passwordWrappedArk = PasswordWrappedArk( - key = rewrapped.ciphertext, - keyIV = rewrapped.nonce, - salt = newSalt, + accountRepository.set( + account.copy( + passwordWrappedArk = PasswordWrappedArk( + key = rewrapped.ciphertext, + keyIV = rewrapped.nonce, + salt = newSalt, + ), ), - ), - ).bind { ChangePasswordError.PersistenceFailed } - - ark.fill(0) + ).bind { ChangePasswordError.PersistenceFailed } + } finally { + // Scrub the in-memory ARK on success *and* on every failure path after unwrap. + ark.fill(0) + } } } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt index aff7aa89..7aad6a56 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/ChangePasswordUseCaseTest.kt @@ -162,4 +162,35 @@ class ChangePasswordUseCaseTest { assertTrue(result.isFailure()) assertEquals(ChangePasswordError.PersistenceFailed, result.error) } + + @Test + fun `scrubs the supplied biometric ARK after a successful change`() = runTest { + seedAccount("old", withBiometric = true) + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } + + @Test + fun `scrubs the supplied biometric ARK when persistence fails`() = runTest { + seedAccount("old", withBiometric = true) + accountRepository.setFails = true + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } + + @Test + fun `scrubs the supplied biometric ARK when biometric reauth is not enrolled`() = runTest { + seedAccount("old", withBiometric = false) + val recovered = ark.copyOf() + + useCase(Reauthentication.Biometric(recovered), "new") + + assertContentEquals(ByteArray(recovered.size), recovered) + } } diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt index 70f6e147..7b8d7823 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -117,8 +117,10 @@ class ChangePasswordViewModelTest { vm.state.value.confirmPassword.edit { append("brand-new") } vm.submitWithPassword() - advanceUntilIdle() + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. + vm.state.first { it.currentPasswordError != FieldError.None } assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) } @@ -201,8 +203,9 @@ class ChangePasswordViewModelTest { vm.state.value.confirmPassword.edit { append("brand-new") } vm.state.value.currentPassword.edit { append("wrong") } vm.submitWithPassword() - advanceUntilIdle() - // currentPasswordError is now FieldError.Incorrect — the assertion below is load-bearing + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. The Incorrect error below is load-bearing. + vm.state.first { it.currentPasswordError == FieldError.Incorrect } vm.dismissReauthDialog() @@ -222,8 +225,10 @@ class ChangePasswordViewModelTest { vm.state.value.currentPassword.edit { append("wrong") } vm.submitWithPassword() // dialog Confirm action - advanceUntilIdle() + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. + vm.state.first { it.currentPasswordError != FieldError.None } assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) assertEquals(true, vm.state.value.showReauthDialog) } diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt index 7cf421b5..66601ddc 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt @@ -4,15 +4,19 @@ import de.davis.keygo.core.util.Result import de.davisalessandro.keygo.rust.KeyDerivationException import de.davisalessandro.keygo.rust.KeyDeriverInterface import de.davisalessandro.keygo.rust.RootKek +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext typealias KeyDeriver = KeyDeriverInterface -fun KeyDeriver.deriveRootKekFromPasswordWithResult( +suspend fun KeyDeriver.deriveRootKekFromPasswordWithResult( password: String, salt: ByteArray, -): Result = runCatching { - deriveRootKekFromPassword(password, salt) -}.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as KeyDerivationException) } -) \ No newline at end of file +): Result = withContext(Dispatchers.Default) { + runCatching { + deriveRootKekFromPassword(password, salt) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyDerivationException) } + ) +} From ef2586c8575f41ad25f60eba1d5a71155a64ea3e Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 19:39:54 +0200 Subject: [PATCH 24/27] fix(settings): correct autofill toggle routing and gate biometrics row Enable requests now open the system autofill selection while disable requests stop the service and refresh state (the branches were swapped). The biometrics toggle is hidden when biometrics are unavailable, enrollment failures surface a snackbar unless the user dismissed the prompt, export/import callbacks are actually invoked, and the event channel is buffered so taps made while a prompt is open are not dropped. Adds a placeholder Connectivity route. Co-Authored-By: Claude Opus 4.8 --- .../keygo/app/presentation/MainActivity.kt | 5 +++ .../settings/presentation/SettingsContent.kt | 2 +- .../settings/presentation/SettingsScreen.kt | 33 +++++++++++++++---- .../settings/presentation/SettingsUiState.kt | 2 +- .../presentation/SettingsViewModel.kt | 12 +++++-- .../settings/src/main/res/values/strings.xml | 1 + .../presentation/component/SettingsDslTest.kt | 28 ++++++++++++++-- 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 621908cc..746f4374 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator @@ -166,6 +167,10 @@ private fun App() { onShowLibraries = { navController.navigate(RouteDestination.Libraries) }, onUp = { navController.navigateUp() }, ) + + composable { + Text("CONNECTIVITY") + } } composable { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index b06fd04a..67836d39 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -44,7 +44,7 @@ internal fun SettingsContent( onClick = { onEvent(SettingsUiEvent.ResetPassword) }, ) - toggle( + if (state.biometricsAvailable) toggle( title = R.string.settings_use_biometrics, icon = Icons.Default.Fingerprint, supporting = R.string.settings_use_biometrics_description, diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index 905b8ab1..a232a34f 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -11,10 +11,17 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.core.net.toUri import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.identity.domain.model.BiometricEnrollmentError import de.davis.keygo.core.identity.presentation.rememberBiometricEnrollmentAdapter import de.davis.keygo.core.identity.presentation.useEnrollmentAdapter +import de.davis.keygo.core.security.domain.model.BiometricAuthError import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString +import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager +import de.davis.keygo.feature.settings.R import org.koin.androidx.compose.koinViewModel @Composable @@ -42,6 +49,7 @@ fun SettingsScreen( val urlHandler = LocalUriHandler.current val context = LocalContext.current + val snackbarManager = LocalSnackbarManager.current ObserveAsEvents(viewModel.event) { when (it) { SettingsEvent.NavigateToLibraries -> showLibraries() @@ -57,21 +65,27 @@ fun SettingsScreen( } is SettingsEvent.EnableBiometric -> { - when { - it.enable -> { - enrollmentAdapter.useEnrollmentAdapter { - biometricController.requestEnableBiometric() - } + val result = when { + it.enable -> enrollmentAdapter.useEnrollmentAdapter { + biometricController.requestEnableBiometric() } else -> enrollmentAdapter.disableBiometric() } + + result.onFailure { error -> + if (!error.isUserDismissal()) snackbarManager.sendMessage( + SnackbarMessage( + message = ResourceString(R.string.settings_biometric_update_failed), + ), + ) + } } SettingsEvent.ReportIssue -> urlHandler.openUri(ISSUES_URL) - SettingsEvent.ExportData -> onExportDataClicked - SettingsEvent.ImportData -> onImportDataClicked + SettingsEvent.ExportData -> onExportDataClicked() + SettingsEvent.ImportData -> onImportDataClicked() } } @@ -81,4 +95,9 @@ fun SettingsScreen( ) } +/** The user backing out of the prompt is not an error worth a snackbar. */ +private fun BiometricEnrollmentError.isUserDismissal(): Boolean = + this is BiometricEnrollmentError.BiometricFailed && + (error == BiometricAuthError.Declined || error == BiometricAuthError.Canceled) + private const val ISSUES_URL = "https://github.com/OffRange/KeyGo/issues/new" \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt index ffa81887..0f89aa61 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -2,7 +2,7 @@ package de.davis.keygo.feature.settings.presentation internal data class SettingsUiState( val autofillEnabled: Boolean = false, - val biometricsAvailable: Boolean = true, + val biometricsAvailable: Boolean = false, val biometricsEnabled: Boolean = false, val version: String = "2.0.0", ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index ec523373..e5c860fd 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -26,7 +26,10 @@ internal class SettingsViewModel( private val versionName = appVersionRepository.versionName - private val _event = Channel() + // Buffered (not rendezvous): the screen handles events in a suspend collector (e.g. while the + // biometric enrollment prompt is open), and a rendezvous trySend would silently drop any tap + // made in the meantime. + private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() // OS-owned state (autofill / biometric availability) has no observable stream of @@ -62,8 +65,11 @@ internal class SettingsViewModel( when (event) { is SettingsUiEvent.SetBiometrics -> _event.trySend(SettingsEvent.EnableBiometric(event.enabled)) is SettingsUiEvent.SetAutofill -> when { - event.enabledRequest -> autofillServiceRepository.disable() - else -> _event.trySend(SettingsEvent.OpenAutofillSelection) + event.enabledRequest -> _event.trySend(SettingsEvent.OpenAutofillSelection) + else -> { + autofillServiceRepository.disable() + refreshSystemState() + } } SettingsUiEvent.ResetPassword -> _event.trySend(SettingsEvent.NavigateToChangePassword) diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 72ac1c73..cf81c365 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Change your main password that protects your data Autofill Fill in your logins in other apps and browsers + Could not update biometric unlock Backup Export Data diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt index 988fbdae..18b8d8a8 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDslTest.kt @@ -1,8 +1,11 @@ package de.davis.keygo.feature.settings.presentation.component +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class SettingsDslTest { @@ -40,13 +43,24 @@ class SettingsDslTest { } @Test - fun `action entry marked as navigation when requested`() { + fun `action entry carries the navigation icon when provided`() { + val icon = testIcon() val entry = SectionScope().apply { - action(title = 11, onClick = {}, isNavigation = true) + action(title = 11, onClick = {}, navigationIcon = icon) }.build().single() val action = assertIs(entry) - assertTrue(action.isNavigation) + assertEquals(icon, action.navigationIcon) + } + + @Test + fun `action entry has no navigation icon by default`() { + val entry = SectionScope().apply { + action(title = 11, onClick = {}) + }.build().single() + + val action = assertIs(entry) + assertNull(action.navigationIcon) } @Test @@ -59,4 +73,12 @@ class SettingsDslTest { assertEquals(listOf(11), entries.map { it.title }) } + + private fun testIcon(): ImageVector = ImageVector.Builder( + name = "test", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).build() } From 310cd3ea9650b500e560514fa8ecaeb257cd4b2c Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 19:40:07 +0200 Subject: [PATCH 25/27] test(settings): provide ViewModel fakes via testFixtures Move FakeAutofillServiceRepository into :feature:autofill testFixtures and add FakeAppVersionRepository to new :feature:settings testFixtures, replacing the fakes previously defined inline in SettingsViewModelTest. Co-Authored-By: Claude Opus 4.8 --- .../autofill/FakeAutofillServiceRepository.kt | 16 ++++ feature/settings/build.gradle.kts | 10 +++ .../presentation/SettingsViewModelTest.kt | 79 +++++++++++++++++++ .../settings/FakeAppVersionRepository.kt | 7 ++ 4 files changed, 112 insertions(+) create mode 100644 feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt create mode 100644 feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt create mode 100644 feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt new file mode 100644 index 00000000..3c6cee19 --- /dev/null +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.core.feature.autofill + +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository + +class FakeAutofillServiceRepository : AutofillServiceRepository { + + var enabled: Boolean = false + var disableCalled: Boolean = false + + override fun isEnabled(): Boolean = enabled + + override fun disable() { + disableCalled = true + enabled = false + } +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 13e978ec..85508c57 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -9,6 +9,10 @@ android { defaultConfig { missingDimensionStrategy("store", "playStore") } + + testFixtures { + enable = true + } } dependencies { @@ -22,4 +26,10 @@ dependencies { testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.feature.autofill)) + + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } } diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt new file mode 100644 index 00000000..c2d966b0 --- /dev/null +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt @@ -0,0 +1,79 @@ +package de.davis.keygo.feature.settings.presentation + +import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository +import de.davis.keygo.core.feature.settings.FakeAppVersionRepository +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val accountRepository = FakeAccountRepository() + private val biometricAvailability = FakeBiometricAvailabilityRepository() + private val autofillServiceRepository = FakeAutofillServiceRepository() + private val appVersionRepository = FakeAppVersionRepository() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = SettingsViewModel( + biometricAvailabilityRepository = biometricAvailability, + autofillServiceRepository = autofillServiceRepository, + accountRepository = accountRepository, + appVersionRepository = appVersionRepository, + ) + + @Test + fun `requesting to enable autofill emits OpenAutofillSelection and does not disable the service`() = + runTest(dispatcher) { + autofillServiceRepository.enabled = false + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetAutofill(enabledRequest = true)) + + assertEquals(SettingsEvent.OpenAutofillSelection, vm.event.first()) + assertFalse(autofillServiceRepository.disableCalled) + } + + @Test + fun `requesting to disable autofill disables the service and refreshes the ui state`() = + runTest(dispatcher) { + autofillServiceRepository.enabled = true + val vm = viewModel() + vm.refreshSystemState() + vm.state.first { it.autofillEnabled } + + vm.onEvent(SettingsUiEvent.SetAutofill(enabledRequest = false)) + + assertTrue(autofillServiceRepository.disableCalled) + vm.state.first { !it.autofillEnabled } + } + + @Test + fun `toggling biometrics forwards the requested value as an EnableBiometric event`() = + runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.SetBiometrics(enabled = true)) + + assertEquals(SettingsEvent.EnableBiometric(enable = true), vm.event.first()) + } +} diff --git a/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt b/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt new file mode 100644 index 00000000..bc79924d --- /dev/null +++ b/feature/settings/src/testFixtures/kotlin/de/davis/keygo/core/feature/settings/FakeAppVersionRepository.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.core.feature.settings + +import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository + +class FakeAppVersionRepository( + override val versionName: String = "2.0.0-test", +) : AppVersionRepository From cbfd93b7ad71702e362633b84f60e59a5a2e1391 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 20:06:55 +0200 Subject: [PATCH 26/27] fix(settings): handle async autofill disable and update biometric default Update the UI state directly in SettingsViewModel when disabling autofill to prevent race conditions with OS polling. Additionally, adjust FakeAutofillServiceRepository to better mirror real-world asynchronous propagation and change the default biometricsAvailable state to false. --- .../core/feature/autofill/FakeAutofillServiceRepository.kt | 4 +++- .../de/davis/keygo/feature/settings/domain/model/OsState.kt | 2 +- .../keygo/feature/settings/presentation/SettingsViewModel.kt | 5 ++++- .../feature/settings/presentation/SettingsViewModelTest.kt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt index 3c6cee19..e6ba0133 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeAutofillServiceRepository.kt @@ -4,6 +4,9 @@ import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceReposito class FakeAutofillServiceRepository : AutofillServiceRepository { + // Mirrors the OS: disableAutofillServices() propagates asynchronously, so + // isEnabled() can keep reporting true right after disable(). Tests control + // `enabled` explicitly instead of disable() flipping it synchronously. var enabled: Boolean = false var disableCalled: Boolean = false @@ -11,6 +14,5 @@ class FakeAutofillServiceRepository : AutofillServiceRepository { override fun disable() { disableCalled = true - enabled = false } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt index 2228b73f..519350f1 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/domain/model/OsState.kt @@ -3,5 +3,5 @@ package de.davis.keygo.feature.settings.domain.model /** Snapshot of OS-owned settings that can only be polled, not observed. */ data class OsState( val autofillEnabled: Boolean = false, - val biometricsAvailable: Boolean = true, + val biometricsAvailable: Boolean = false, ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index e5c860fd..f5ef2ee5 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -68,7 +68,10 @@ internal class SettingsViewModel( event.enabledRequest -> _event.trySend(SettingsEvent.OpenAutofillSelection) else -> { autofillServiceRepository.disable() - refreshSystemState() + // disableAutofillServices() propagates through the system server + // asynchronously, so re-polling isEnabled() here can still read true. + // The outcome is deterministic — update the snapshot directly. + osState.update { it.copy(autofillEnabled = false) } } } diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt index c2d966b0..da0fb665 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt @@ -54,7 +54,7 @@ class SettingsViewModelTest { } @Test - fun `requesting to disable autofill disables the service and refreshes the ui state`() = + fun `requesting to disable autofill disables the service and updates the ui`() = runTest(dispatcher) { autofillServiceRepository.enabled = true val vm = viewModel() From 14ae1e73552d139e5df7538cb61f9cdbc3a45d68 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 10 Jun 2026 20:14:38 +0200 Subject: [PATCH 27/27] fix(settings): animate entries --- .../presentation/component/SettingsList.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index f4cc23c9..d0ae373c 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -45,12 +45,15 @@ internal fun SettingsList( ) { sections.forEach { section -> stickyHeader(key = "header_${section.title}") { - SettingsSectionHeader(title = section.title) + SettingsSectionHeader( + title = section.title, + modifier = Modifier.animateItem() + ) } itemsIndexed( items = section.entries, - key = { index, entry -> "${section.title}_${index}_${entry.title}" }, + key = { _, entry -> "${section.title}_${entry.title}" }, ) { index, entry -> SettingsEntryRow( entry = entry, @@ -59,6 +62,7 @@ internal fun SettingsList( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.animateItem() ) } } @@ -71,7 +75,8 @@ private fun SettingsEntryRow( entry: SettingsEntry, shapes: ListItemShapes, colors: ListItemColors, - verticalAlignment: Alignment.Vertical + verticalAlignment: Alignment.Vertical, + modifier: Modifier = Modifier, ) { val leadingContent: (@Composable () -> Unit)? = entry.icon?.let { icon -> { @@ -120,6 +125,7 @@ private fun SettingsEntryRow( } ) }, + modifier = modifier, content = headlineContent, ) @@ -138,6 +144,7 @@ private fun SettingsEntryRow( ) } } else null, + modifier = modifier, content = headlineContent, ) @@ -149,6 +156,7 @@ private fun SettingsEntryRow( supportingContent = supportingContent, verticalAlignment = verticalAlignment, trailingContent = { Text(text = entry.value) }, + modifier = modifier, content = headlineContent, ) }