From e712aaf496bf63b7a84fe072b6887ceede649824 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 15 Sep 2026 19:56:20 +0200 Subject: [PATCH 01/54] feat: add password-health module --- app/build.gradle.kts | 1 + .../keygo/app/presentation/AppDestinations.kt | 8 ++++ .../presentation/navigation/EntryProvider.kt | 5 +++ app/src/main/res/values/strings.xml | 1 + feature/password-health/build.gradle.kts | 12 ++++++ feature/password-health/consumer-rules.pro | 0 .../di/FeaturePasswordHealthModule.kt | 10 +++++ .../presentation/PasswordHealthContent.kt | 41 +++++++++++++++++++ .../presentation/PasswordHealthEntries.kt | 16 ++++++++ .../presentation/PasswordHealthScreen.kt | 8 ++++ settings.gradle.kts | 1 + 11 files changed, 103 insertions(+) create mode 100644 feature/password-health/build.gradle.kts create mode 100644 feature/password-health/consumer-rules.pro create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5d49e7fc..376955630 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -140,6 +140,7 @@ dependencies { implementation(projects.feature.settings) implementation(projects.feature.backup) implementation(projects.feature.onboarding) + implementation(projects.feature.passwordHealth) implementation(projects.legacyMigration) implementation(libs.androidx.core.splashscreen) 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 fab65b7c3..3896accb4 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 @@ -3,12 +3,14 @@ package de.davis.keygo.app.presentation import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.NavKey import de.davis.keygo.R import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute import de.davis.keygo.feature.settings.presentation.SettingsRoute /** The navigation bar's destinations. */ @@ -19,6 +21,12 @@ enum class AppDestinations( @StringRes val contentDescription: Int ) { HOME(RouteDestination.Home, R.string.home, Icons.Default.Home, R.string.home), + PASSWORD_HEALTH( + PasswordHealthRoute, + R.string.password_health, + Icons.Default.HealthAndSafety, + R.string.password_health + ), CONNECTIVITY( RouteDestination.Connectivity, R.string.connectivity, diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt index 5bcb6e0bc..018878005 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -29,6 +29,7 @@ import de.davis.keygo.feature.item.create.presentation.totp.AssignTotpRoute import de.davis.keygo.feature.item.create.presentation.totp.assignTotpEntries import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute import de.davis.keygo.feature.onboarding.presentation.onboardingEntries +import de.davis.keygo.feature.password_health.presentation.passwordHealthEntries import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.settingsEntries import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute @@ -129,6 +130,10 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> ) } } + + passwordHealthEntries( + metadata = NavigationOnly, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9855c7b6..048924ade 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Add new Element Home + Password Health Connectivity Settings diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts new file mode 100644 index 000000000..67bdebb58 --- /dev/null +++ b/feature/password-health/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.feature.password_health" +} + +dependencies { + implementation(projects.core.ui) +} diff --git a/feature/password-health/consumer-rules.pro b/feature/password-health/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt new file mode 100644 index 000000000..195229179 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.password_health.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.password_health") +object FeaturePasswordHealthModule diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt new file mode 100644 index 000000000..c09ff4d24 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItemDefaults +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 + +@Composable +internal fun PasswordHealthContent() { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + + } + } +} + +@Preview +@Composable +private fun PasswordHealthContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PasswordHealthContent() + } + } +} \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt new file mode 100644 index 000000000..2ec20ff7c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +object PasswordHealthRoute : NavKey + +fun EntryProviderScope.passwordHealthEntries( + metadata: Map = emptyMap(), +) { + entry(metadata = metadata) { + PasswordHealthScreen() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt new file mode 100644 index 000000000..ab2cbc8ea --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.runtime.Composable + +@Composable +fun PasswordHealthScreen() { + PasswordHealthContent() +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 57c645075..6cdf5b03d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,3 +51,4 @@ include(":feature:credit-card") include(":feature:settings") include(":feature:backup") include(":feature:onboarding") +include(":feature:password-health") From b69a2a820058abc12a919e0ad46cd3fbc51b5485 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Thu, 17 Sep 2026 13:37:41 +0200 Subject: [PATCH 02/54] feat: add first prototype UI --- .../keygo/core/ui/components/KeyGoCard.kt | 20 ++- .../keygo/core/ui/components/KeyGoSwitch.kt | 3 + feature/password-health/build.gradle.kts | 2 + .../presentation/PasswordHealthContent.kt | 91 ++++++++-- .../presentation/PasswordHealthScreen.kt | 11 +- .../presentation/PasswordHealthViewModel.kt | 19 +++ .../presentation/SegmentContainerColor.kt | 10 ++ .../presentation/component/BreachCheck.kt | 46 ++++++ .../component/NeedsAttentionSection.kt | 130 +++++++++++++++ .../component/PasswordHealthStatus.kt | 156 ++++++++++++++++++ .../model/PasswordHealthUiEvent.kt | 8 + .../model/PasswordHealthUiState.kt | 119 +++++++++++++ .../src/main/res/values/strings.xml | 35 ++++ 13 files changed, 634 insertions(+), 16 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt create mode 100644 feature/password-health/src/main/res/values/strings.xml diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt index 661ab5421..2420c78e9 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt @@ -36,17 +36,29 @@ data class KeyGoCardProperties( companion object { @Composable - fun outlined(containerColor: Color = Color.Unspecified) = KeyGoCardProperties( + fun outlined( + containerColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified + ) = KeyGoCardProperties( shape = CardDefaults.outlinedShape, - colors = CardDefaults.outlinedCardColors(containerColor = containerColor), + colors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor + ), elevation = CardDefaults.outlinedCardElevation(), border = CardDefaults.outlinedCardBorder() ) @Composable - fun elevated(containerColor: Color = Color.Unspecified) = KeyGoCardProperties( + fun elevated( + containerColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified + ) = KeyGoCardProperties( shape = CardDefaults.elevatedShape, - colors = CardDefaults.elevatedCardColors(containerColor = containerColor), + colors = CardDefaults.elevatedCardColors( + containerColor = containerColor, + contentColor = contentColor + ), elevation = CardDefaults.elevatedCardElevation(), border = null ) diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt index 8fe56909e..feccdca6c 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.ListItemShapes import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -21,6 +22,7 @@ fun KeyGoSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + verticalAlignment: Alignment.Vertical = ListItemDefaults.verticalAlignment(), overlineContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, @@ -35,6 +37,7 @@ fun KeyGoSwitch( overlineContent = overlineContent, supportingContent = supportingContent, leadingContent = leadingContent, + verticalAlignment = verticalAlignment, trailingContent = { Switch( checked = checked, diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 67bdebb58..0f1c30c4d 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { implementation(projects.core.ui) + implementation(projects.core.item) + implementation(projects.feature.item.create) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index c09ff4d24..46a0ef702 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -1,41 +1,110 @@ package de.davis.keygo.feature.password_health.presentation -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ListItemDefaults +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.component.BreachCheck +import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.component.needsAttentionSection +import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType +import de.davis.keygo.feature.password_health.presentation.model.verdict @Composable -internal fun PasswordHealthContent() { +internal fun PasswordHealthContent( + state: PasswordHealthUiState, + onEvent: (PasswordHealthUiEvent) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + topBar = { + MediumFlexibleTopAppBar( + title = { + Text(text = stringResource(R.string.password_health_title)) + }, + subtitle = { + Text(text = state.verdict(), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + scrollBehavior = scrollBehavior, + ) + } ) { innerPadding -> - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .consumeWindowInsets(innerPadding), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + .consumeWindowInsets(innerPadding) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(horizontal = 8.dp), ) { + item(key = "status") { + PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) + } + + if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) needsAttentionSection(state = state) + item(key = "breach_check") { + Spacer(modifier = Modifier.height(28.dp)) + BreachCheck( + state = state, + onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, + modifier = Modifier.animateItem() + ) + } } } + + if (state.generatePassword) GeneratePasswordModalBottomSheet( + onGenerated = { onEvent(PasswordHealthUiEvent.PasswordGenerated(it)) }, + onDismiss = { onEvent(PasswordHealthUiEvent.DismissGeneratePassword) }, + ) } @Preview @Composable private fun PasswordHealthContentPreview() { MaterialTheme { - Surface(modifier = Modifier.fillMaxSize()) { - PasswordHealthContent() + Surface( + modifier = Modifier.fillMaxSize(), + ) { + PasswordHealthContent( + state = PasswordHealthUiState( + totalPasswordCount = 12, + attentionEntries = listOf( + AttentionEntry( + id = newItemId(), + title = "Weak password", + username = "user@mail.com", + issueType = PasswordIssueType.Weak, + ), + ) + ), + onEvent = {}, + ) } } -} \ No newline at end of file +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt index ab2cbc8ea..f5ba267c3 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -1,8 +1,17 @@ package de.davis.keygo.feature.password_health.presentation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.androidx.compose.koinViewModel @Composable fun PasswordHealthScreen() { - PasswordHealthContent() + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + PasswordHealthContent( + state = state, + onEvent = viewModel::onEvent, + ) } \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt new file mode 100644 index 000000000..f742923a2 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.lifecycle.ViewModel +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class PasswordHealthViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(PasswordHealthUiState()) + val uiState = _uiState.asStateFlow() + + fun onEvent(event: PasswordHealthUiEvent) { + + } +} \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt new file mode 100644 index 000000000..b3508b70c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.password_health.presentation + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + + +internal val segmentContainerColor: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainerHigh diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt new file mode 100644 index 000000000..7d56f8f0b --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -0,0 +1,46 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +@Composable +internal fun BreachCheck( + state: PasswordHealthUiState, + onChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + KeyGoSwitch( + checked = state.breachCheckEnabled, + onCheckedChange = onChange, + modifier = modifier, + supportingContent = { + Text(text = stringResource(R.string.breach_check_opt_in_description)) + }, + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large) + ) { + Text(text = stringResource(R.string.breach_check_enable)) + } +} + +@Preview +@Composable +private fun BreachCheckPreview() { + MaterialTheme { + BreachCheck( + state = PasswordHealthUiState(), + onChange = {} + ) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt new file mode 100644 index 000000000..2afb9b57b --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -0,0 +1,130 @@ +package de.davis.keygo.feature.password_health.presentation.component + +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.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +internal fun LazyListScope.needsAttentionSection( + state: PasswordHealthUiState, +) { + item(key = "NeedsAttentionSection-header") { + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = stringResource(R.string.needs_attention_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + itemsIndexed( + items = state.attentionEntries, + key = { _, entry -> entry.id } + ) { index, entry -> + AttentionRow( + entry = entry, + shapes = ListItemDefaults.segmentedShapes(index, state.attentionEntries.size), + modifier = Modifier.animateItem() + ) + } +} + +@Composable +private fun AttentionRow( + entry: AttentionEntry, + shapes: ListItemShapes, + modifier: Modifier = Modifier, +) { + SegmentedListItem( + shapes = shapes, + modifier = modifier, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + overlineContent = if (entry.issueType is PasswordIssueType.Breached) { + { + Text( + text = pluralStringResource( + R.plurals.needs_attention_found_in_breach, + entry.issueType.count, + entry.issueType.count, + ), + color = MaterialTheme.colorScheme.error + ) + } + } else null, + supportingContent = entry.username?.let { username -> + { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = username, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = entry.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Preview +@Composable +private fun NeedsAttentionSectionPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + needsAttentionSection( + state = PasswordHealthUiState( + totalPasswordCount = 10, + attentionEntries = listOf( + AttentionEntry( + id = newItemId(), + title = "Example 1", + username = null, + issueType = PasswordIssueType.Weak + ), + AttentionEntry( + id = newItemId(), + title = "Example 2", + username = "User", + issueType = PasswordIssueType.Reused + ), + AttentionEntry( + id = newItemId(), + title = "Example 3", + username = null, + issueType = PasswordIssueType.Breached(count = 5) + ), + ) + ) + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt new file mode 100644 index 000000000..ec93e8ec4 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -0,0 +1,156 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.size +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType +import de.davis.keygo.feature.password_health.presentation.model.detailLine +import de.davis.keygo.feature.password_health.presentation.model.icon +import de.davis.keygo.feature.password_health.presentation.model.toneColor +import de.davis.keygo.feature.password_health.presentation.model.verdict + +@Composable +internal fun PasswordHealthStatus( + state: PasswordHealthUiState, + modifier: Modifier = Modifier +) { + val containerColorTarget = state.toneColor() + val containerColor by animateColorAsState( + targetValue = containerColorTarget, + label = "containerColor" + ) + val contentColor by animateColorAsState( + targetValue = contentColorFor(containerColorTarget), + label = "contentColor" + ) + + KeyGoCard( + title = { + Text(text = stringResource(R.string.password_health_overview_label)) + }, + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + properties = KeyGoCardProperties.elevated( + containerColor = containerColor, + contentColor = contentColor + ), + leadingItem = { + AnimatedContent(state.isLoading) { + when (it) { + true -> CircularWavyProgressIndicator( + modifier = Modifier.size(40.dp) + ) + + false -> Icon( + imageVector = state.status.icon(), + contentDescription = null, + ) + } + } + } + ) { + Text(text = state.verdict()) + + val detail = state.detailLine() + + AnimatedVisibility( + visible = detail != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + label = "detailLineVisibility" + ) { + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Preview +@Composable +private fun AllGoodPreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PasswordHealthStatus(state = PasswordHealthUiState()) + PasswordHealthStatus( + state = PasswordHealthUiState( + isLoading = true + ) + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 12, + ) + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 12, + attentionEntries = listOf( + AttentionEntry( + id = newItemId(), + title = "Password #1", + username = null, + issueType = PasswordIssueType.Reused + ), + AttentionEntry( + id = newItemId(), + title = "Password #2", + username = null, + issueType = PasswordIssueType.Breached(count = 1) + ), + AttentionEntry( + id = newItemId(), + title = "Password #3", + username = null, + issueType = PasswordIssueType.Breached(count = 5) + ), + AttentionEntry( + id = newItemId(), + title = "Password #4", + username = null, + issueType = PasswordIssueType.Weak + ), + ), + ), + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt new file mode 100644 index 000000000..d5cf975bc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.presentation.model + +internal sealed interface PasswordHealthUiEvent { + data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent + + data class PasswordGenerated(val password: String) : PasswordHealthUiEvent + data object DismissGeneratePassword : PasswordHealthUiEvent +} \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt new file mode 100644 index 000000000..a2fe31f8f --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -0,0 +1,119 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GppBad +import androidx.compose.material.icons.filled.GppGood +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.R + +@Stable +internal sealed interface PasswordIssueType { + data object Weak : PasswordIssueType + data object Reused : PasswordIssueType + data class Breached(val count: Int) : PasswordIssueType +} + +@Stable +internal data class AttentionEntry( + val id: ItemId, + val title: String, + val username: String?, + val issueType: PasswordIssueType +) + +internal enum class PasswordHealthStatus { + NO_DATA, + ALL_GOOD, + NEEDS_ATTENTION +} + +@Stable +internal data class PasswordHealthUiState( + val isLoading: Boolean = false, + val breachCheckEnabled: Boolean = false, + val totalPasswordCount: Int = 0, + val attentionEntries: List = emptyList(), + val generatePassword: Boolean = false, +) { + val breachedCount = attentionEntries.count { it.issueType is PasswordIssueType.Breached } + val weakCount = attentionEntries.count { it.issueType == PasswordIssueType.Weak } + val reusedCount = attentionEntries.count { it.issueType == PasswordIssueType.Reused } + + val status = when { + totalPasswordCount == 0 -> PasswordHealthStatus.NO_DATA + attentionEntries.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION + else -> PasswordHealthStatus.ALL_GOOD + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.toneColor(): Color { + if (isLoading) return MaterialTheme.colorScheme.surfaceContainerHigh + return when (status) { + PasswordHealthStatus.NO_DATA -> MaterialTheme.colorScheme.surfaceContainerHigh + PasswordHealthStatus.ALL_GOOD -> MaterialTheme.colorScheme.secondaryContainer + PasswordHealthStatus.NEEDS_ATTENTION -> MaterialTheme.colorScheme.errorContainer + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthStatus.icon() = when (this) { + PasswordHealthStatus.NO_DATA -> Icons.Default.HealthAndSafety + PasswordHealthStatus.ALL_GOOD -> Icons.Default.GppGood + PasswordHealthStatus.NEEDS_ATTENTION -> Icons.Default.GppBad +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.verdict(): String { + if (isLoading) return stringResource(R.string.checking_passwords) + return when (status) { + PasswordHealthStatus.NO_DATA -> stringResource(R.string.password_health_no_data) + PasswordHealthStatus.ALL_GOOD -> stringResource(R.string.password_health_all_good) + PasswordHealthStatus.NEEDS_ATTENTION -> pluralStringResource( + R.plurals.password_health_needs_attention, + attentionEntries.size, + attentionEntries.size + ) + } +} + +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.detailLine(): String? { + if (isLoading) return null + + return when (status) { + PasswordHealthStatus.NO_DATA -> null + PasswordHealthStatus.ALL_GOOD -> pluralStringResource( + R.plurals.detail_line_checked, + totalPasswordCount, + totalPasswordCount + ) + + PasswordHealthStatus.NEEDS_ATTENTION -> { + val checked = stringResource(R.string.detail_line_checked_short, totalPasswordCount) + val breached = + if (breachedCount > 0) stringResource(R.string.detail_line_breached, breachedCount) + else null + val weak = + if (weakCount > 0) stringResource(R.string.detail_line_weak, weakCount) + else null + val reused = + if (reusedCount > 0) stringResource(R.string.detail_line_reused, reusedCount) + else null + + listOfNotNull(checked, breached, weak, reused).joinToString(" \u2022 ") + } + } +} diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml new file mode 100644 index 000000000..032b88780 --- /dev/null +++ b/feature/password-health/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + Password Health + + Overview + Add some passwords to see how they\'re doing + All your passwords look great + + %1$d password needs attention + %1$d passwords need attention + + Checking your passwords… + + + %1$d password checked + %1$d passwords checked + + %1$d checked + %1$d breached + %1$d weak + %1$d reused + + Needs attention + Weak + Reused + Breached + + + Found in %1$d breach + Found in %1$d breaches + + + Check for breaches + Only the first 5 characters of each password\'s hash ever leave your device + From 73936faf28fb55227f94d285e8e97461e19b7dd1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 18 Sep 2026 18:48:43 +0200 Subject: [PATCH 03/54] feat: add weak health check prototype --- feature/password-health/build.gradle.kts | 6 + .../di/FeaturePasswordHealthModule.kt | 2 +- .../domain/checker/PasswordHealthChecker.kt | 11 + .../domain/checker/WeakPasswordChecker.kt | 25 +++ .../password_health/domain/model/CheckKind.kt | 8 + .../domain/model/FindingSeverity.kt | 7 + .../domain/model/HealthFinding.kt | 15 ++ .../password_health/domain/model/ItemIssue.kt | 15 ++ .../domain/model/PasswordCandidate.kt | 12 + .../domain/model/PasswordHealthReport.kt | 33 +++ .../domain/model/PasswordHealthReportError.kt | 5 + .../domain/model/RelationType.kt | 6 + .../usecase/PasswordHealthReportUseCase.kt | 121 ++++++++++ .../presentation/PasswordHealthContent.kt | 39 +++- .../presentation/PasswordHealthScreen.kt | 6 + .../presentation/PasswordHealthViewModel.kt | 48 +++- .../component/NeedsAttentionSection.kt | 210 ++++++++++++++---- .../component/PasswordHealthStatus.kt | 29 --- .../model/PasswordHealthUiEvent.kt | 2 + .../model/PasswordHealthUiState.kt | 101 ++++++--- .../src/main/res/values/strings.xml | 13 ++ 21 files changed, 590 insertions(+), 124 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 0f1c30c4d..86465d224 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -5,10 +5,16 @@ plugins { android { namespace = "de.davis.keygo.feature.password_health" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } } dependencies { implementation(projects.core.ui) implementation(projects.core.item) + implementation(projects.core.util) + implementation(projects.core.security) implementation(projects.feature.item.create) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 195229179..758846b0c 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -7,4 +7,4 @@ import org.koin.core.annotation.Module @Module @Configuration @ComponentScan("de.davis.keygo.feature.password_health") -object FeaturePasswordHealthModule +object FeaturePasswordHealthModule \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt new file mode 100644 index 000000000..8b9647b69 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate + +interface PasswordHealthChecker { + val type: CheckKind + + suspend fun check(candidates: List): List +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt new file mode 100644 index 000000000..962ef1fc5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import org.koin.core.annotation.Single + +@Single +internal class WeakPasswordChecker : PasswordHealthChecker { + override val type = CheckKind.Strength + + override suspend fun check(candidates: List): List = + candidates.filter { it.score in WEAK_SCORES }.map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Weak(score = it.score) + ) + } + + companion object { + private val WEAK_SCORES = setOf(PasswordScore.Ridiculous, PasswordScore.Weak) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt new file mode 100644 index 000000000..1700b0feb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckKind.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface CheckKind { + data object Strength : CheckKind + data object Reuse : CheckKind + data object Breach : CheckKind + data object Similarity : CheckKind +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt new file mode 100644 index 000000000..33e281bec --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/FindingSeverity.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class FindingSeverity { + Medium, + High, + Critical, +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt new file mode 100644 index 000000000..3849762b1 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthFinding.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +sealed interface HealthFinding { + data class Item( + val id: ItemId, + val issue: ItemIssue, + ) : HealthFinding + + data class Relation( + val relatedItemIds: Set, + val type: RelationType, + ) : HealthFinding +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt new file mode 100644 index 000000000..7964ce57b --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemIssue.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore + +sealed interface ItemIssue { + val severity: FindingSeverity + + data class Weak(val score: PasswordScore) : ItemIssue { + override val severity = FindingSeverity.Medium + } + + data class Breached(val occurrences: Int) : ItemIssue { + override val severity = FindingSeverity.Critical + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt new file mode 100644 index 000000000..f9b71d52f --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore + +class PasswordCandidate( + val id: ItemId, + val title: String, + val username: String?, + val score: PasswordScore, + val password: CharArray, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt new file mode 100644 index 000000000..c4f5cb07d --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +data class ItemHealth( + val itemId: ItemId, + val title: String, + val username: String?, + val issues: List +) { + val maxSeverity: FindingSeverity? = issues.maxOfOrNull { it.severity } + + val breach: ItemIssue.Breached? = issues.filterIsInstance().firstOrNull() + val weak: ItemIssue.Weak? = issues.filterIsInstance().firstOrNull() +} + +data class RelatedGroup( + val id: ItemId, + val members: Set, + val relations: Set, +) { + val maxSeverity = + (members.mapNotNull { it.maxSeverity } + relations.map { it.type.severity }).max() + + val dominantRelation: RelationType = relations.maxBy { it.type.severity }.type + val orderedMembers: List = members.sortedByDescending { it.maxSeverity } +} + +data class PasswordHealthReport( + val groups: List, + val standalone: List, + val totalPasswordsScanned: Int, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt new file mode 100644 index 000000000..5096bb213 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface PasswordHealthReportError { + data object NoPasswords : PasswordHealthReportError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt new file mode 100644 index 000000000..bc9d01f9c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/RelationType.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class RelationType(val severity: FindingSeverity) { + Reused(FindingSeverity.High), + Similar(FindingSeverity.Medium), +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt new file mode 100644 index 000000000..b92596112 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -0,0 +1,121 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.firstOrNull +import org.koin.core.annotation.Single + +@Single +class PasswordHealthReportUseCase( + private val loginRepository: LoginRepository, + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val checker: List, +) { + + suspend operator fun invoke(): Result = + resultBinding { + val candidates = loginRepository.observeLogins() + .firstOrNull() + ?.mapNotNull { + it.passwordCredential?.let { credential -> + cryptographicScopeProvider.itemScope( + itemId = it.id, + ) { + PasswordCandidate( + id = it.id, + title = it.name, + username = it.username, + score = credential.score, + password = credential.secret.decrypt().toCharArray(), + ) + }.getOrNull() // TODO + } + } + .asResult(PasswordHealthReportError.NoPasswords) + .bind() + + val findings = try { + coroutineScope { + checker.map { async { it.check(candidates) } }.awaitAll().flatten() + } + } finally { + candidates.forEach { it.password.fill('\u0000') } + } + + val issuesById = findings.filterIsInstance() + .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) + val relations = findings.filterIsInstance() + + // Connected components over all relation edges + val uf = UnionFind() + relations.forEach { r -> + val first = r.relatedItemIds.first() + r.relatedItemIds.forEach { uf.union(first, it) } + } + + fun health(item: PasswordCandidate) = ItemHealth( + itemId = item.id, + title = item.title, + username = item.username, + issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, + ) + + val (related, unrelated) = candidates.partition { uf.contains(it.id) } + val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } + + val group = related.groupBy { uf.find(it.id) } + .map { (root, members) -> + RelatedGroup( + id = root, + members = members.mapTo( + mutableSetOf(), + ::health + ), + relations = relationsById.getValue(root).toSet() + ) + } + .sortedByDescending { it.maxSeverity } + + val standalone = unrelated.map(::health) + .filter { it.issues.isNotEmpty() } + .sortedByDescending { it.maxSeverity } + + PasswordHealthReport( + groups = group, + standalone = standalone, + totalPasswordsScanned = candidates.size + ) + } +} + +private class UnionFind { + private val parent = HashMap() + fun contains(x: T) = x in parent + + fun find(x: T): T { + val p = parent.getOrPut(x) { x } + return if (p == x) x else find(p).also { parent[x] = it } + } + + fun union(a: T, b: T) { + val ra = find(a) + val rb = find(b) + if (ra != rb) parent[ra] = rb + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index 46a0ef702..e54263cd6 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.password_health.presentation +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold @@ -21,16 +23,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.presentation.component.BreachCheck import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthStatus import de.davis.keygo.feature.password_health.presentation.component.needsAttentionSection -import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry +import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState -import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType import de.davis.keygo.feature.password_health.presentation.model.verdict @Composable @@ -59,13 +64,15 @@ internal fun PasswordHealthContent( .padding(innerPadding) .consumeWindowInsets(innerPadding) .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = PaddingValues(horizontal = 8.dp), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { item(key = "status") { PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) } - if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) needsAttentionSection(state = state) + if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) + needsAttentionSection(sections = state.healthSections) item(key = "breach_check") { Spacer(modifier = Modifier.height(28.dp)) @@ -94,14 +101,22 @@ private fun PasswordHealthContentPreview() { PasswordHealthContent( state = PasswordHealthUiState( totalPasswordCount = 12, - attentionEntries = listOf( - AttentionEntry( - id = newItemId(), - title = "Weak password", - username = "user@mail.com", - issueType = PasswordIssueType.Weak, - ), - ) + healthSections = listOf( + HealthSection( + severity = FindingSeverity.Medium, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Weak password", + username = null, + issues = listOf( + ItemIssue.Weak(score = PasswordScore.Weak) + ) + ) + ) + ) + ), ), onEvent = {}, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt index f5ba267c3..c9fea67cc 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -1,8 +1,10 @@ package de.davis.keygo.feature.password_health.presentation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import org.koin.androidx.compose.koinViewModel @Composable @@ -10,6 +12,10 @@ fun PasswordHealthScreen() { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.onEvent(PasswordHealthUiEvent.RunHealthCheck) + } + PasswordHealthContent( state = state, onEvent = viewModel::onEvent, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index f742923a2..20f263d14 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -1,19 +1,61 @@ package de.davis.keygo.feature.password_health.presentation +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @KoinViewModel -internal class PasswordHealthViewModel : ViewModel() { +internal class PasswordHealthViewModel( + private val passwordHealth: PasswordHealthReportUseCase +) : ViewModel() { - private val _uiState = MutableStateFlow(PasswordHealthUiState()) + private val _uiState = MutableStateFlow(PasswordHealthUiState(isLoading = true)) val uiState = _uiState.asStateFlow() fun onEvent(event: PasswordHealthUiEvent) { + when (event) { + PasswordHealthUiEvent.RunHealthCheck -> { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val report = passwordHealth().getOrNull() // TODO + _uiState.update { + it.copy( + isLoading = false, + healthSections = report?.toSections().orEmpty(), + totalPasswordCount = report?.totalPasswordsScanned ?: 0 + ) + } + Log.d("PasswordHealthViewModel", "Report: $report") + } + } + + PasswordHealthUiEvent.DismissGeneratePassword -> {} + is PasswordHealthUiEvent.OnBreachCheckChanged -> {} + is PasswordHealthUiEvent.PasswordGenerated -> {} + } + } +} + +private fun PasswordHealthReport.toSections(): List { + val groupsBySeverity = groups.groupBy { it.maxSeverity } + val standaloneBySeverity = standalone.groupBy { requireNotNull(it.maxSeverity) } + + return FindingSeverity.entries.sortedDescending().mapNotNull { severity -> + val g = groupsBySeverity[severity].orEmpty().sortedByDescending { it.members.size } + val s = standaloneBySeverity[severity].orEmpty().sortedByDescending { it.issues.size } + if (g.isEmpty() && s.isEmpty()) null else HealthSection(severity, g, s) } -} \ No newline at end of file +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt index 2afb9b57b..51715a242 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -1,10 +1,13 @@ package de.davis.keygo.feature.password_health.presentation.component +import androidx.annotation.StringRes 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.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed @@ -23,16 +26,22 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.feature.password_health.R -import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry -import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState -import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.segmentContainerColor internal fun LazyListScope.needsAttentionSection( - state: PasswordHealthUiState, + sections: List, ) { - item(key = "NeedsAttentionSection-header") { + item(key = "needs-attention-title", contentType = ContentType.Title) { Spacer(modifier = Modifier.height(28.dp)) Text( text = stringResource(R.string.needs_attention_title), @@ -41,56 +50,142 @@ internal fun LazyListScope.needsAttentionSection( Spacer(modifier = Modifier.height(12.dp)) } + sections.forEach { section -> + item( + key = "severity-${section.severity}", + contentType = ContentType.SeverityHeader + ) { + Text( + text = stringResource(section.severity.labelRes), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .animateItem() + .padding(top = 12.dp, bottom = 4.dp), + ) + } + + section.groups.forEach { group -> + item(key = "group-${group.id}", contentType = ContentType.GroupLabel) { + GroupLabel(group = group, modifier = Modifier.animateItem()) + } + + segmentedRows(group.orderedMembers) + } + + segmentedRows(section.standalone) + } +} + +private fun LazyListScope.segmentedRows(items: List) { itemsIndexed( - items = state.attentionEntries, - key = { _, entry -> entry.id } - ) { index, entry -> - AttentionRow( - entry = entry, - shapes = ListItemDefaults.segmentedShapes(index, state.attentionEntries.size), - modifier = Modifier.animateItem() + items = items, + key = { _, item -> item.itemId }, + contentType = { _, _ -> ContentType.Row }, + ) { index, item -> + ItemHealthRow( + itemHealth = item, + shapes = segmentedShapesFor(index = index, count = items.size), + modifier = Modifier.animateItem(), ) } } @Composable -private fun AttentionRow( - entry: AttentionEntry, +private fun GroupLabel( + group: RelatedGroup, + modifier: Modifier = Modifier, +) { + val count = group.members.size + val label = when (group.dominantRelation) { + RelationType.Reused -> R.plurals.needs_attention_group_reused + RelationType.Similar -> R.plurals.needs_attention_group_similar + } + + Text( + text = pluralStringResource(label, count, count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(start = 16.dp, top = 8.dp, bottom = 2.dp), + ) +} + +@Composable +private fun ItemHealthRow( + itemHealth: ItemHealth, shapes: ListItemShapes, modifier: Modifier = Modifier, ) { + val breach = itemHealth.breach + val weak = itemHealth.weak + val username = itemHealth.username + SegmentedListItem( shapes = shapes, - modifier = modifier, + modifier = modifier.fillMaxWidth(), colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), - overlineContent = if (entry.issueType is PasswordIssueType.Breached) { + overlineContent = breach?.let { breached -> { Text( text = pluralStringResource( R.plurals.needs_attention_found_in_breach, - entry.issueType.count, - entry.issueType.count, + breached.occurrences, + breached.occurrences, ), - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, ) } - } else null, - supportingContent = entry.username?.let { username -> + }, + supportingContent = if (username != null || weak != null) { { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = username, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (username != null) Text( + text = username, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (weak != null) StrengthIndicator(passwordScore = weak.score) } } - }, + } else null, verticalAlignment = Alignment.CenterVertically, ) { - Text(text = entry.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = itemHealth.title, maxLines = 1, overflow = TextOverflow.Ellipsis) } } +@Composable +private fun segmentedShapesFor(index: Int, count: Int): ListItemShapes = + if (count == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) + else ListItemDefaults.segmentedShapes(index, count) + +private val FindingSeverity.labelRes: Int + @StringRes + get() = when (this) { + FindingSeverity.Critical -> R.string.needs_attention_severity_critical + FindingSeverity.High -> R.string.needs_attention_severity_high + FindingSeverity.Medium -> R.string.needs_attention_severity_medium + } + +private enum class ContentType { + Title, + SeverityHeader, + GroupLabel, + Row, +} + @Preview @Composable private fun NeedsAttentionSectionPreview() { + val reused = List(3) { index -> + ItemHealth( + itemId = newItemId(), + title = listOf("Reddit", "Twitch", "Steam")[index], + username = "user@example.com", + issues = emptyList(), + ) + } + MaterialTheme { Surface( modifier = Modifier.fillMaxSize() @@ -100,29 +195,56 @@ private fun NeedsAttentionSectionPreview() { verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) ) { needsAttentionSection( - state = PasswordHealthUiState( - totalPasswordCount = 10, - attentionEntries = listOf( - AttentionEntry( - id = newItemId(), - title = "Example 1", - username = null, - issueType = PasswordIssueType.Weak + sections = listOf( + HealthSection( + severity = FindingSeverity.Critical, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "GitHub", + username = "user@example.com", + issues = listOf(ItemIssue.Breached(occurrences = 4)), + ), ), - AttentionEntry( - id = newItemId(), - title = "Example 2", - username = "User", - issueType = PasswordIssueType.Reused + ), + HealthSection( + severity = FindingSeverity.High, + groups = listOf( + RelatedGroup( + id = reused.first().itemId, + members = reused.toSet(), + relations = setOf( + HealthFinding.Relation( + relatedItemIds = reused.mapTo(mutableSetOf()) { it.itemId }, + type = RelationType.Reused, + ), + ), + ), ), - AttentionEntry( - id = newItemId(), - title = "Example 3", - username = null, - issueType = PasswordIssueType.Breached(count = 5) + standalone = emptyList(), + ), + HealthSection( + severity = FindingSeverity.Medium, + groups = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Old forum account", + username = null, + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + ItemHealth( + itemId = newItemId(), + title = "Router admin", + username = "admin", + issues = listOf( + ItemIssue.Weak(score = PasswordScore.Ridiculous), + ), + ), ), - ) - ) + ), + ), ) } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt index ec93e8ec4..0dda28fc7 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -25,13 +25,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.feature.password_health.R -import de.davis.keygo.feature.password_health.presentation.model.AttentionEntry import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState -import de.davis.keygo.feature.password_health.presentation.model.PasswordIssueType import de.davis.keygo.feature.password_health.presentation.model.detailLine import de.davis.keygo.feature.password_health.presentation.model.icon import de.davis.keygo.feature.password_health.presentation.model.toneColor @@ -122,32 +119,6 @@ private fun AllGoodPreview() { PasswordHealthStatus( state = PasswordHealthUiState( totalPasswordCount = 12, - attentionEntries = listOf( - AttentionEntry( - id = newItemId(), - title = "Password #1", - username = null, - issueType = PasswordIssueType.Reused - ), - AttentionEntry( - id = newItemId(), - title = "Password #2", - username = null, - issueType = PasswordIssueType.Breached(count = 1) - ), - AttentionEntry( - id = newItemId(), - title = "Password #3", - username = null, - issueType = PasswordIssueType.Breached(count = 5) - ), - AttentionEntry( - id = newItemId(), - title = "Password #4", - username = null, - issueType = PasswordIssueType.Weak - ), - ), ), ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt index d5cf975bc..f097178cd 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -1,6 +1,8 @@ package de.davis.keygo.feature.password_health.presentation.model internal sealed interface PasswordHealthUiEvent { + data object RunHealthCheck : PasswordHealthUiEvent + data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent data class PasswordGenerated(val password: String) : PasswordHealthUiEvent diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index a2fe31f8f..4d5df67f2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.password_health.presentation.model +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.GppBad import androidx.compose.material.icons.filled.GppGood @@ -11,23 +12,12 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.feature.password_health.R - -@Stable -internal sealed interface PasswordIssueType { - data object Weak : PasswordIssueType - data object Reused : PasswordIssueType - data class Breached(val count: Int) : PasswordIssueType -} - -@Stable -internal data class AttentionEntry( - val id: ItemId, - val title: String, - val username: String?, - val issueType: PasswordIssueType -) +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType internal enum class PasswordHealthStatus { NO_DATA, @@ -35,21 +25,63 @@ internal enum class PasswordHealthStatus { NEEDS_ATTENTION } +data class HealthSummary( + val needsAttention: Int, + val weak: Int, + val breached: Int, + val reused: Int, + val similar: Int, +) + +internal fun List.summary(): HealthSummary { + val groups = fold(mutableListOf()) { acc, section -> + acc.addAll(section.groups) + acc + } + val standalone = fold(mutableListOf()) { acc, section -> + acc.addAll(section.standalone) + acc + } + + + val flagged = groups.flatMap { it.members } + standalone + + fun countWith(predicate: (ItemIssue) -> Boolean) = + flagged.count { item -> item.issues.any(predicate) } + + fun countRelated(type: RelationType) = groups + .flatMap { group -> group.relations.filter { it.type == type } } + .flatMapTo(mutableSetOf()) { it.relatedItemIds } + .size + + return HealthSummary( + needsAttention = flagged.size, + weak = countWith { it is ItemIssue.Weak }, + breached = countWith { it is ItemIssue.Breached }, + reused = countRelated(RelationType.Reused), + similar = countRelated(RelationType.Similar), + ) +} + +internal data class HealthSection( + val severity: FindingSeverity, + val groups: List, + val standalone: List +) + @Stable internal data class PasswordHealthUiState( val isLoading: Boolean = false, val breachCheckEnabled: Boolean = false, val totalPasswordCount: Int = 0, - val attentionEntries: List = emptyList(), + val healthSections: List = emptyList(), val generatePassword: Boolean = false, ) { - val breachedCount = attentionEntries.count { it.issueType is PasswordIssueType.Breached } - val weakCount = attentionEntries.count { it.issueType == PasswordIssueType.Weak } - val reusedCount = attentionEntries.count { it.issueType == PasswordIssueType.Reused } + val summary by lazy { healthSections.summary() } val status = when { totalPasswordCount == 0 -> PasswordHealthStatus.NO_DATA - attentionEntries.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION + healthSections.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION else -> PasswordHealthStatus.ALL_GOOD } } @@ -82,8 +114,8 @@ internal fun PasswordHealthUiState.verdict(): String { PasswordHealthStatus.ALL_GOOD -> stringResource(R.string.password_health_all_good) PasswordHealthStatus.NEEDS_ATTENTION -> pluralStringResource( R.plurals.password_health_needs_attention, - attentionEntries.size, - attentionEntries.size + summary.needsAttention, + summary.needsAttention ) } } @@ -102,18 +134,17 @@ internal fun PasswordHealthUiState.detailLine(): String? { ) PasswordHealthStatus.NEEDS_ATTENTION -> { - val checked = stringResource(R.string.detail_line_checked_short, totalPasswordCount) - val breached = - if (breachedCount > 0) stringResource(R.string.detail_line_breached, breachedCount) - else null - val weak = - if (weakCount > 0) stringResource(R.string.detail_line_weak, weakCount) - else null - val reused = - if (reusedCount > 0) stringResource(R.string.detail_line_reused, reusedCount) - else null - - listOfNotNull(checked, breached, weak, reused).joinToString(" \u2022 ") + listOfNotNull( + countLabel(R.string.detail_line_checked_short, totalPasswordCount), + countLabel(R.string.detail_line_breached, summary.breached), + countLabel(R.string.detail_line_weak, summary.weak), + countLabel(R.string.detail_line_reused, summary.reused), + ).joinToString(" \u2022 ") } } } + +@Composable +@ReadOnlyComposable +private fun countLabel(@StringRes id: Int, count: Int): String? = + if (count > 0) stringResource(id, count, count) else null diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 032b88780..8ca408980 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -25,6 +25,19 @@ Reused Breached + Critical + High + Needs review + + + Reused across %1$d login + Reused across %1$d logins + + + Similar password across %1$d login + Similar passwords across %1$d logins + + Found in %1$d breach Found in %1$d breaches From 3bfd00e41e12ee1c63b15dfcec17399d94359f19 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 19 Sep 2026 00:55:54 +0200 Subject: [PATCH 04/54] feat: add reuse check --- .../domain/checker/ReusePasswordCheck.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt new file mode 100644 index 000000000..5d5009af5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.RelationType +import org.koin.core.annotation.Single + +@Single +internal class ReusePasswordCheck : PasswordHealthChecker { + + override val type = CheckKind.Reuse + + override suspend fun check(candidates: List): List = + candidates.groupBy { PasswordKey(it.password) } + .values + .filter { it.size > 1 } + .map { candidates -> + HealthFinding.Relation( + relatedItemIds = candidates.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused + ) + } + +} + +private class PasswordKey(private val value: CharArray) { + override fun equals(other: Any?) = other is PasswordKey && value.contentEquals(other.value) + override fun hashCode() = value.contentHashCode() +} From db4e7bdd92f088587760a57d5fe144a0bc52b871 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 19 Sep 2026 22:30:55 +0200 Subject: [PATCH 05/54] fix: dominantRelation --- .../domain/model/PasswordHealthReport.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index c4f5cb07d..7fd863136 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -22,7 +22,21 @@ data class RelatedGroup( val maxSeverity = (members.mapNotNull { it.maxSeverity } + relations.map { it.type.severity }).max() - val dominantRelation: RelationType = relations.maxBy { it.type.severity }.type + /** + * What the group is mostly about, which is what its label says. + * + * A group is one connected component over every relation edge, so a couple of exact + * copies can sit inside a much wider similarity chain. + */ + val dominantRelation: RelationType = relations + .groupBy { it.type } + .entries + .maxWith( + compareBy>> { (_, sharing) -> + sharing.flatMapTo(mutableSetOf()) { it.relatedItemIds }.size + }.thenBy { (type, _) -> type.severity }, + ) + .key val orderedMembers: List = members.sortedByDescending { it.maxSeverity } } From 3ae314f0eb444261d684eed4a8ee126c1d5fdd9f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 19 Sep 2026 23:39:49 +0200 Subject: [PATCH 06/54] fix: add similar count label --- .../password_health/presentation/model/PasswordHealthUiState.kt | 1 + feature/password-health/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 4d5df67f2..d6890a836 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -139,6 +139,7 @@ internal fun PasswordHealthUiState.detailLine(): String? { countLabel(R.string.detail_line_breached, summary.breached), countLabel(R.string.detail_line_weak, summary.weak), countLabel(R.string.detail_line_reused, summary.reused), + countLabel(R.string.detail_line_similar, summary.similar), ).joinToString(" \u2022 ") } } diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 8ca408980..35cd21458 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ %1$d breached %1$d weak %1$d reused + %1$d similar Needs attention Weak From be9f1e48e2b825627836281fa2c0073d50c090ad Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 01:57:24 +0200 Subject: [PATCH 07/54] feat: make checkers return result --- .../domain/checker/PasswordHealthChecker.kt | 6 ++- .../domain/checker/ReusePasswordCheck.kt | 29 +++++++++----- .../domain/checker/WeakPasswordChecker.kt | 22 ++++++---- .../domain/model/CheckError.kt | 7 ++++ .../password_health/domain/model/CheckGap.kt | 8 ++++ .../domain/model/CheckOutcome.kt | 6 +++ .../domain/model/PasswordHealthReport.kt | 1 + .../usecase/PasswordHealthReportUseCase.kt | 27 +++++++++++-- .../presentation/PasswordHealthViewModel.kt | 1 + .../presentation/component/BreachCheck.kt | 40 ++++++++++++++++++- .../model/PasswordHealthUiState.kt | 11 ++++- .../src/main/res/values/strings.xml | 9 +++++ 12 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt index 8b9647b69..74bb075b4 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt @@ -1,11 +1,13 @@ package de.davis.keygo.feature.password_health.domain.checker +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind -import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate interface PasswordHealthChecker { val type: CheckKind - suspend fun check(candidates: List): List + suspend fun check(candidates: List): Result } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt index 5d5009af5..2bf461f6e 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt @@ -1,6 +1,9 @@ package de.davis.keygo.feature.password_health.domain.checker +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.model.RelationType @@ -11,17 +14,21 @@ internal class ReusePasswordCheck : PasswordHealthChecker { override val type = CheckKind.Reuse - override suspend fun check(candidates: List): List = - candidates.groupBy { PasswordKey(it.password) } - .values - .filter { it.size > 1 } - .map { candidates -> - HealthFinding.Relation( - relatedItemIds = candidates.mapTo(mutableSetOf()) { it.id }, - type = RelationType.Reused - ) - } - + override suspend fun check( + candidates: List, + ): Result = Result.Success( + CheckOutcome( + findings = candidates.groupBy { PasswordKey(it.password) } + .values + .filter { it.size > 1 } + .map { sharing -> + HealthFinding.Relation( + relatedItemIds = sharing.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused, + ) + }, + ), + ) } private class PasswordKey(private val value: CharArray) { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt index 962ef1fc5..2a84d1ba1 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt @@ -1,7 +1,10 @@ package de.davis.keygo.feature.password_health.domain.checker import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate @@ -11,13 +14,18 @@ import org.koin.core.annotation.Single internal class WeakPasswordChecker : PasswordHealthChecker { override val type = CheckKind.Strength - override suspend fun check(candidates: List): List = - candidates.filter { it.score in WEAK_SCORES }.map { - HealthFinding.Item( - id = it.id, - issue = ItemIssue.Weak(score = it.score) - ) - } + override suspend fun check( + candidates: List, + ): Result = Result.Success( + CheckOutcome( + findings = candidates.filter { it.score in WEAK_SCORES }.map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Weak(score = it.score), + ) + }, + ), + ) companion object { private val WEAK_SCORES = setOf(PasswordScore.Ridiculous, PasswordScore.Weak) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt new file mode 100644 index 000000000..74a6d873d --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface CheckError { + + data object Unreachable : CheckError + data object Failed : CheckError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt new file mode 100644 index 000000000..511fc55ab --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +data class CheckGap( + val error: CheckError, + val unchecked: Set, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt new file mode 100644 index 000000000..11480c7b6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +data class CheckOutcome( + val findings: List, + val gap: CheckGap? = null, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index 7fd863136..053ced731 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -44,4 +44,5 @@ data class PasswordHealthReport( val groups: List, val standalone: List, val totalPasswordsScanned: Int, + val gaps: Map = emptyMap(), ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index b92596112..6b89a97af 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -9,6 +9,9 @@ import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.CheckError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate @@ -50,14 +53,21 @@ class PasswordHealthReportUseCase( .asResult(PasswordHealthReportError.NoPasswords) .bind() - val findings = try { + val outcomes = try { coroutineScope { - checker.map { async { it.check(candidates) } }.awaitAll().flatten() + checker.map { async { it.type to it.check(candidates) } }.awaitAll() } } finally { candidates.forEach { it.password.fill('\u0000') } } + val findings = outcomes.flatMap { (_, outcome) -> + outcome.getOrNull()?.findings.orEmpty() + } + val gaps = outcomes.mapNotNull { (kind, outcome) -> + outcome.gap(candidates)?.let { kind to it } + }.toMap() + val issuesById = findings.filterIsInstance() .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) val relations = findings.filterIsInstance() @@ -99,11 +109,22 @@ class PasswordHealthReportUseCase( PasswordHealthReport( groups = group, standalone = standalone, - totalPasswordsScanned = candidates.size + totalPasswordsScanned = candidates.size, + gaps = gaps, ) } } +private fun Result.gap(candidates: List): CheckGap? = + when (this) { + is Result.Failure -> CheckGap( + error = error, + unchecked = candidates.mapTo(mutableSetOf()) { it.id }, + ) + + is Result.Success -> success.gap + } + private class UnionFind { private val parent = HashMap() fun contains(x: T) = x in parent diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 20f263d14..52950212e 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -34,6 +34,7 @@ internal class PasswordHealthViewModel( it.copy( isLoading = false, healthSections = report?.toSections().orEmpty(), + checkGaps = report?.gaps.orEmpty(), totalPasswordCount = report?.totalPasswordsScanned ?: 0 ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt index 7d56f8f0b..e30a9758e 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -4,12 +4,18 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.ui.components.KeyGoSwitch import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.segmentContainerColor @@ -19,12 +25,15 @@ internal fun BreachCheck( onChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val gap = state.breachGap + KeyGoSwitch( checked = state.breachCheckEnabled, onCheckedChange = onChange, modifier = modifier, supportingContent = { - Text(text = stringResource(R.string.breach_check_opt_in_description)) + if (gap == null) Text(text = stringResource(R.string.breach_check_opt_in_description)) + else Text(text = gap.message(), color = MaterialTheme.colorScheme.error) }, verticalAlignment = Alignment.CenterVertically, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), @@ -34,6 +43,17 @@ internal fun BreachCheck( } } +@Composable +@ReadOnlyComposable +private fun CheckGap.message(): String = pluralStringResource( + when (error) { + CheckError.Unreachable -> R.plurals.breach_check_unreachable + CheckError.Failed -> R.plurals.breach_check_failed + }, + unchecked.size, + unchecked.size, +) + @Preview @Composable private fun BreachCheckPreview() { @@ -44,3 +64,21 @@ private fun BreachCheckPreview() { ) } } + +@Preview +@Composable +private fun BreachCheckUnavailablePreview() { + MaterialTheme { + BreachCheck( + state = PasswordHealthUiState( + checkGaps = mapOf( + CheckKind.Breach to CheckGap( + error = CheckError.Unreachable, + unchecked = setOf(newItemId(), newItemId(), newItemId()), + ), + ), + ), + onChange = {} + ) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index d6890a836..5b44914e9 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.ItemIssue @@ -75,10 +77,13 @@ internal data class PasswordHealthUiState( val breachCheckEnabled: Boolean = false, val totalPasswordCount: Int = 0, val healthSections: List = emptyList(), + val checkGaps: Map = emptyMap(), val generatePassword: Boolean = false, ) { val summary by lazy { healthSections.summary() } + val breachGap: CheckGap? = checkGaps[CheckKind.Breach] + val status = when { totalPasswordCount == 0 -> PasswordHealthStatus.NO_DATA healthSections.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION @@ -111,7 +116,11 @@ internal fun PasswordHealthUiState.verdict(): String { if (isLoading) return stringResource(R.string.checking_passwords) return when (status) { PasswordHealthStatus.NO_DATA -> stringResource(R.string.password_health_no_data) - PasswordHealthStatus.ALL_GOOD -> stringResource(R.string.password_health_all_good) + + PasswordHealthStatus.ALL_GOOD -> + if (checkGaps.isEmpty()) stringResource(R.string.password_health_all_good) + else stringResource(R.string.password_health_checked_all_good) + PasswordHealthStatus.NEEDS_ATTENTION -> pluralStringResource( R.plurals.password_health_needs_attention, summary.needsAttention, diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 35cd21458..9c6351d12 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Overview Add some passwords to see how they\'re doing All your passwords look great + Every password that could be checked looks great %1$d password needs attention %1$d passwords need attention @@ -46,4 +47,12 @@ Check for breaches Only the first 5 characters of each password\'s hash ever leave your device + + %1$d password went unchecked: couldn\'t reach the breach service + %1$d passwords went unchecked: couldn\'t reach the breach service + + + %1$d password went unchecked: the breach service didn\'t answer + %1$d passwords went unchecked: the breach service didn\'t answer + From ccce3895be9d8dc5b80c3b065e136ba98d047371 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 02:25:36 +0200 Subject: [PATCH 08/54] feat: add pull to refresh --- .../presentation/PasswordHealthContent.kt | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index e54263cd6..73e259bed 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -58,29 +59,36 @@ internal fun PasswordHealthContent( ) } ) { innerPadding -> - LazyColumn( + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = { onEvent(PasswordHealthUiEvent.RunHealthCheck) }, modifier = Modifier .fillMaxSize() .padding(innerPadding) .consumeWindowInsets(innerPadding) - .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - item(key = "status") { - PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) - } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item(key = "status") { + PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) + } - if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) - needsAttentionSection(sections = state.healthSections) + if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) + needsAttentionSection(sections = state.healthSections) - item(key = "breach_check") { - Spacer(modifier = Modifier.height(28.dp)) - BreachCheck( - state = state, - onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, - modifier = Modifier.animateItem() - ) + item(key = "breach_check") { + Spacer(modifier = Modifier.height(28.dp)) + BreachCheck( + state = state, + onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, + modifier = Modifier.animateItem() + ) + } } } } From 94054d99c7bbd39c85315499b39366699919d789 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 02:28:23 +0200 Subject: [PATCH 09/54] feat: add breached check --- feature/password-health/build.gradle.kts | 2 + .../src/main/AndroidManifest.xml | 4 + .../data/repository/BreachedRepositoryImpl.kt | 161 ++++++++++++++++++ .../di/FeaturePasswordHealthModule.kt | 32 +++- .../di/annotation/BreachedQualifier.kt | 6 + .../domain/checker/BreachedPasswordCheck.kt | 126 ++++++++++++++ .../domain/model/BreachedError.kt | 8 + .../domain/repository/BreachedRepository.kt | 16 ++ 8 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 feature/password-health/src/main/AndroidManifest.xml create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 86465d224..4cc6e3f12 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -17,4 +17,6 @@ dependencies { implementation(projects.core.util) implementation(projects.core.security) implementation(projects.feature.item.create) + + implementation(libs.okhttp) } diff --git a/feature/password-health/src/main/AndroidManifest.xml b/feature/password-health/src/main/AndroidManifest.xml new file mode 100644 index 000000000..458644fa1 --- /dev/null +++ b/feature/password-health/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt new file mode 100644 index 000000000..66e4d34d6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImpl.kt @@ -0,0 +1,161 @@ +package de.davis.keygo.feature.password_health.data.repository + +import android.util.Log +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.data.repository.BreachedRepositoryImpl.Companion.MAX_RANGE_LINES +import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.BufferedSource +import org.koin.core.annotation.Single +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Single +internal class BreachedRepositoryImpl( + @param:BreachedQualifier + private val http: OkHttpClient, +) : BreachedRepository { + + override suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> { + if (!prefix.isRangePrefix()) { + Log.w(TAG, "Refusing to ask for a range that is not $PREFIX_LENGTH hex characters") + return Result.Failure(BreachedError.InvalidPrefix) + } + + if (suffixes.isEmpty()) return Result.Success(emptyMap()) + + val request = Request.Builder() + .url(RANGE_URL.toHttpUrl().newBuilder().addPathSegment(prefix).build()) + .header("Add-Padding", "true") + .header("Cache-Control", "no-store") + .build() + + return request.fetch(suffixes) + } + + private suspend fun Request.fetch( + suffixes: Set, + ): Result, BreachedError> { + var attempt = 1 + + while (true) { + when (val outcome = send(suffixes)) { + is Attempt.Answered -> return Result.Success(outcome.counts) + is Attempt.Failed -> return Result.Failure(outcome.error) + + is Attempt.Throttled -> { + if (attempt == MAX_ATTEMPTS) { + Log.w(TAG, "The breach API still throttled us after $attempt attempts") + return Result.Failure(BreachedError.ApiFailed) + } + + attempt++ + delay(outcome.backoff) + } + } + } + } + + private suspend fun Request.send(suffixes: Set): Attempt = + suspendCancellableCoroutine { cont -> + val call = http.newCall(this) + cont.invokeOnCancellation { call.cancel() } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.w(TAG, "Could not reach the breach API", e) + cont.resume(Attempt.Failed(BreachedError.Unreachable)) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response.use { it.read(suffixes) }) + } + }) + } + + private fun Response.read(suffixes: Set): Attempt { + if (code == HTTP_TOO_MANY_REQUESTS) return Attempt.Throttled(backoff()) + + if (!isSuccessful) { + Log.w(TAG, "The breach API answered with $code") + return Attempt.Failed(BreachedError.ApiFailed) + } + + val counts = body.source().readCounts(suffixes) + if (counts == null) { + Log.w(TAG, "The range did not end within $MAX_RANGE_LINES lines") + return Attempt.Failed(BreachedError.ApiFailed) + } + + return Attempt.Answered(counts) + } + + private fun Response.backoff(): Duration = header("Retry-After") + ?.toLongOrNull() + ?.seconds + ?.coerceIn(MIN_BACKOFF, MAX_BACKOFF) + ?: MIN_BACKOFF + + /** Null when the range ran past [MAX_RANGE_LINES] without ending. */ + private fun BufferedSource.readCounts(suffixes: Set): Map? { + val counts = mutableMapOf() + var lines = 0 + + while (lines++ < MAX_RANGE_LINES) { + val line = readUtf8Line() ?: return counts + + val separator = line.indexOf(':') + if (separator <= 0) continue + + // The matched suffix is reused as the key, so a line we care about is the only thing + // in the response that turns into a string. + val suffix = suffixes.firstOrNull { + it.length == separator && line.startsWith(it, ignoreCase = true) + } ?: continue + + val count = line.substring(separator + 1).trim().toIntOrNull() ?: continue + if (count > 0) counts[suffix] = count + } + + // What we read is not an answer: the suffix we asked about could be past the cap, and + // reporting that as a clean range is the one mistake this check must not make. + return null + } + + private fun String.isRangePrefix() = + length == PREFIX_LENGTH && all { it.digitToIntOrNull(radix = 16) != null } + + private sealed interface Attempt { + data class Answered(val counts: Map) : Attempt + data class Throttled(val backoff: Duration) : Attempt + data class Failed(val error: BreachedError) : Attempt + } + + companion object { + private const val TAG = "BreachedRepositoryImpl" + + private const val RANGE_URL = "https://api.pwnedpasswords.com/range/" + private const val HTTP_TOO_MANY_REQUESTS = 429 + + private const val MAX_ATTEMPTS = 3 + private val MIN_BACKOFF = 1.seconds + private val MAX_BACKOFF = 10.seconds + + private const val MAX_RANGE_LINES = 8_192 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 758846b0c..74817445a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -1,10 +1,40 @@ package de.davis.keygo.feature.password_health.di +import android.content.Context +import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import okhttp3.Dispatcher +import okhttp3.OkHttpClient import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.seconds @Module @Configuration @ComponentScan("de.davis.keygo.feature.password_health") -object FeaturePasswordHealthModule \ No newline at end of file +object FeaturePasswordHealthModule { + + private val LOOKUP_TIMEOUT = 15.seconds + private const val MAX_PARALLEL_LOOKUPS = 8 + + @Single + @BreachedQualifier + internal fun provideBreachedHttpClient(applicationContext: Context): OkHttpClient { + val userAgent = "KeyGo/${applicationContext.versionName()}" + + return OkHttpClient.Builder() + .cache(null) + .callTimeout(LOOKUP_TIMEOUT) + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_PARALLEL_LOOKUPS }) + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder().header("User-Agent", userAgent).build(), + ) + } + .build() + } + + private fun Context.versionName(): String = + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt new file mode 100644 index 000000000..3fe4a4b72 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/BreachedQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class BreachedQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt new file mode 100644 index 000000000..96b48d4fb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -0,0 +1,126 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import android.util.Log +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.nio.CharBuffer +import java.security.MessageDigest + +@Single +internal class BreachedPasswordCheck( + private val breachedRepository: BreachedRepository, +) : PasswordHealthChecker { + + override val type = CheckKind.Breach + + override suspend fun check( + candidates: List, + ): Result { + if (candidates.isEmpty()) return Result.Success(CheckOutcome(findings = emptyList())) + + val ranges = candidates.byRange() + + val answers = coroutineScope { + ranges.map { (prefix, bySuffix) -> + async { bySuffix to breachedRepository.occurrences(prefix, bySuffix.keys) } + }.awaitAll() + } + + val findings = mutableListOf() + val unchecked = mutableSetOf() + val errors = mutableSetOf() + + answers.forEach { (bySuffix, answer) -> + when (answer) { + is Result.Success -> findings += answer.success.findings(bySuffix) + + is Result.Failure -> { + bySuffix.values.forEach { sharing -> sharing.mapTo(unchecked) { it.id } } + errors += answer.error.asCheckError() + } + } + } + + val gap = when { + unchecked.isEmpty() -> null + + // A connection that is down explains the run better than one range answering oddly. + CheckError.Unreachable in errors -> + CheckGap(error = CheckError.Unreachable, unchecked = unchecked) + + else -> CheckGap(error = CheckError.Failed, unchecked = unchecked) + } + + // Not a single range came back, so there is no partly covered run to report. + if (gap != null && answers.all { (_, answer) -> answer is Result.Failure }) + return Result.Failure(gap.error) + + if (gap != null) Log.w( + TAG, + "${unchecked.size} of ${candidates.size} passwords went unchecked: ${gap.error}", + ) + + return Result.Success(CheckOutcome(findings = findings, gap = gap)) + } + + private fun Map.findings( + bySuffix: Map>, + ): List = flatMap { (suffix, occurrences) -> + bySuffix[suffix].orEmpty().map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Breached(occurrences = occurrences), + ) + } + } + + private fun BreachedError.asCheckError() = when (this) { + BreachedError.Unreachable -> CheckError.Unreachable + BreachedError.ApiFailed, BreachedError.InvalidPrefix -> CheckError.Failed + } + + private suspend fun List.byRange(): Map>> { + val sha1 = MessageDigest.getInstance("SHA-1") + val ranges = mutableMapOf>>() + + forEach { candidate -> + val hash = sha1.hash(candidate.password) + ranges.getOrPut(hash.take(PREFIX_LENGTH)) { mutableMapOf() } + .getOrPut(hash.substring(PREFIX_LENGTH)) { mutableListOf() } + .add(candidate) + } + + return ranges + } + + private suspend fun MessageDigest.hash(password: CharArray): String = + withContext(Dispatchers.Default) { + val encoded = Charsets.UTF_8.encode(CharBuffer.wrap(password)) + try { + update(encoded) + return@withContext digest().toHexString(format = HexFormat.UpperCase) + } finally { + if (encoded.hasArray()) encoded.array().fill(0) + } + } + + companion object { + private const val TAG = "BreachedPasswordCheck" + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt new file mode 100644 index 000000000..56fc02616 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachedError.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface BreachedError { + + data object ApiFailed : BreachedError + data object Unreachable : BreachedError + data object InvalidPrefix : BreachedError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt new file mode 100644 index 000000000..f225f3943 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachedRepository.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError + +interface BreachedRepository { + + suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> + + companion object { + const val PREFIX_LENGTH = 5 + } +} From 8954e4d52abb0970dd47f857b89ac695fac0bfc1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 12:27:03 +0200 Subject: [PATCH 10/54] feat: add breach check state protobuf --- feature/password-health/build.gradle.kts | 3 +++ .../data/mapper/BreachCheckStateMapper.kt | 8 ++++++ .../BreachCheckStateRepositoryImpl.kt | 26 +++++++++++++++++++ .../di/FeaturePasswordHealthModule.kt | 16 ++++++++++++ .../domain/model/BreachCheckState.kt | 5 ++++ .../repository/BreachCheckStateRepository.kt | 8 ++++++ .../src/main/proto/breach_check_state.proto | 8 ++++++ 7 files changed, 74 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt create mode 100644 feature/password-health/src/main/proto/breach_check_state.proto diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 4cc6e3f12..5c109fecd 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.keygo.android.protobuf) alias(libs.plugins.kotlin.serialization) } @@ -19,4 +20,6 @@ dependencies { implementation(projects.feature.item.create) implementation(libs.okhttp) + + implementation(libs.androidx.datastore) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt new file mode 100644 index 000000000..51b632096 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.domain.model.BreachCheckState + +internal fun ProtoBreachCheckState.toDomain() = BreachCheckState( + enabled = enabled +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt new file mode 100644 index 000000000..b40bb27ec --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.data.mapper.toDomain +import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.domain.model.BreachCheckState +import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class BreachCheckStateRepositoryImpl( + @BreachedQualifier + private val dataStore: DataStore, +) : BreachCheckStateRepository { + + override suspend fun getBreachCheckState(): BreachCheckState = + dataStore.data.first().toDomain() + + override suspend fun setBreachEnabled(enabled: Boolean) { + dataStore.updateData { + it.toBuilder().setEnabled(enabled).build() + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 74817445a..0f15a88b8 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -1,6 +1,9 @@ package de.davis.keygo.feature.password_health.di import android.content.Context +import androidx.datastore.dataStore +import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer +import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier import okhttp3.Dispatcher import okhttp3.OkHttpClient @@ -35,6 +38,19 @@ object FeaturePasswordHealthModule { .build() } + private val Context.breachCheckStateDataStore by dataStore( + "breach_check_state.pb", + DefaultProtoSerializer( + defaultInstance = ProtoBreachCheckState.getDefaultInstance(), + parser = ProtoBreachCheckState.parser() + ) + ) + + @Single + @BreachedQualifier + internal fun provideBreachCheckStateDataStore(context: Context) = + context.breachCheckStateDataStore + private fun Context.versionName(): String = packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt new file mode 100644 index 000000000..abfe3ca38 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.password_health.domain.model + +data class BreachCheckState( + val enabled: Boolean, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt new file mode 100644 index 000000000..3e3b3b75a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.feature.password_health.domain.model.BreachCheckState + +interface BreachCheckStateRepository { + suspend fun getBreachCheckState(): BreachCheckState + suspend fun setBreachEnabled(enabled: Boolean) +} diff --git a/feature/password-health/src/main/proto/breach_check_state.proto b/feature/password-health/src/main/proto/breach_check_state.proto new file mode 100644 index 000000000..26f1af48b --- /dev/null +++ b/feature/password-health/src/main/proto/breach_check_state.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.backup.data.local.model; +option java_multiple_files = true; + +message ProtoBreachCheckState { + bool enabled = 1; +} From 13fc53fd9ac0a67018c212150b4bbb2d560ec04d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 12:27:23 +0200 Subject: [PATCH 11/54] refactor: change check return type --- .../domain/checker/BreachedPasswordCheck.kt | 37 +++++----- .../domain/checker/PasswordHealthChecker.kt | 4 +- .../domain/checker/ReusePasswordCheck.kt | 26 +++---- .../domain/checker/WeakPasswordChecker.kt | 20 ++---- .../domain/model/CheckError.kt | 7 -- .../password_health/domain/model/CheckGap.kt | 2 +- .../domain/model/CheckOutcome.kt | 15 +++- .../password_health/domain/model/GapReason.kt | 7 ++ .../domain/model/PasswordHealthReport.kt | 1 + .../domain/model/PasswordHealthReportError.kt | 1 + .../usecase/PasswordHealthReportUseCase.kt | 69 ++++++++++--------- .../presentation/PasswordHealthViewModel.kt | 58 +++++++++++----- .../presentation/component/BreachCheck.kt | 30 ++++---- .../component/PasswordHealthStatus.kt | 59 ++++++++++++---- .../model/PasswordHealthUiState.kt | 41 ++++++++++- .../src/main/res/values/strings.xml | 7 ++ 16 files changed, 243 insertions(+), 141 deletions(-) delete mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt index 96b48d4fb..de828cad3 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -4,13 +4,14 @@ import android.util.Log import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.util.Result import de.davis.keygo.feature.password_health.domain.model.BreachedError -import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH import kotlinx.coroutines.Dispatchers @@ -25,14 +26,16 @@ import java.security.MessageDigest @Single internal class BreachedPasswordCheck( private val breachedRepository: BreachedRepository, + private val breachCheckStateRepository: BreachCheckStateRepository, ) : PasswordHealthChecker { override val type = CheckKind.Breach - override suspend fun check( - candidates: List, - ): Result { - if (candidates.isEmpty()) return Result.Success(CheckOutcome(findings = emptyList())) + override suspend fun check(candidates: List): CheckOutcome { + if (candidates.isEmpty()) return CheckOutcome() + + if (!breachCheckStateRepository.getBreachCheckState().enabled) + return CheckOutcome.skipped(GapReason.Disabled, candidates) val ranges = candidates.byRange() @@ -44,7 +47,7 @@ internal class BreachedPasswordCheck( val findings = mutableListOf() val unchecked = mutableSetOf() - val errors = mutableSetOf() + val reasons = mutableSetOf() answers.forEach { (bySuffix, answer) -> when (answer) { @@ -52,7 +55,7 @@ internal class BreachedPasswordCheck( is Result.Failure -> { bySuffix.values.forEach { sharing -> sharing.mapTo(unchecked) { it.id } } - errors += answer.error.asCheckError() + reasons += answer.error.asGapReason() } } } @@ -61,22 +64,18 @@ internal class BreachedPasswordCheck( unchecked.isEmpty() -> null // A connection that is down explains the run better than one range answering oddly. - CheckError.Unreachable in errors -> - CheckGap(error = CheckError.Unreachable, unchecked = unchecked) + GapReason.Unreachable in reasons -> + CheckGap(reason = GapReason.Unreachable, unchecked = unchecked) - else -> CheckGap(error = CheckError.Failed, unchecked = unchecked) + else -> CheckGap(reason = GapReason.Failed, unchecked = unchecked) } - // Not a single range came back, so there is no partly covered run to report. - if (gap != null && answers.all { (_, answer) -> answer is Result.Failure }) - return Result.Failure(gap.error) - if (gap != null) Log.w( TAG, - "${unchecked.size} of ${candidates.size} passwords went unchecked: ${gap.error}", + "${unchecked.size} of ${candidates.size} passwords went unchecked: ${gap.reason}", ) - return Result.Success(CheckOutcome(findings = findings, gap = gap)) + return CheckOutcome(findings = findings, gap = gap) } private fun Map.findings( @@ -90,9 +89,9 @@ internal class BreachedPasswordCheck( } } - private fun BreachedError.asCheckError() = when (this) { - BreachedError.Unreachable -> CheckError.Unreachable - BreachedError.ApiFailed, BreachedError.InvalidPrefix -> CheckError.Failed + private fun BreachedError.asGapReason() = when (this) { + BreachedError.Unreachable -> GapReason.Unreachable + BreachedError.ApiFailed, BreachedError.InvalidPrefix -> GapReason.Failed } private suspend fun List.byRange(): Map>> { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt index 74bb075b4..ffe8a57a6 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordHealthChecker.kt @@ -1,7 +1,5 @@ package de.davis.keygo.feature.password_health.domain.checker -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate @@ -9,5 +7,5 @@ import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate interface PasswordHealthChecker { val type: CheckKind - suspend fun check(candidates: List): Result + suspend fun check(candidates: List): CheckOutcome } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt index 2bf461f6e..7225a7966 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheck.kt @@ -1,7 +1,5 @@ package de.davis.keygo.feature.password_health.domain.checker -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding @@ -14,20 +12,16 @@ internal class ReusePasswordCheck : PasswordHealthChecker { override val type = CheckKind.Reuse - override suspend fun check( - candidates: List, - ): Result = Result.Success( - CheckOutcome( - findings = candidates.groupBy { PasswordKey(it.password) } - .values - .filter { it.size > 1 } - .map { sharing -> - HealthFinding.Relation( - relatedItemIds = sharing.mapTo(mutableSetOf()) { it.id }, - type = RelationType.Reused, - ) - }, - ), + override suspend fun check(candidates: List): CheckOutcome = CheckOutcome( + findings = candidates.groupBy { PasswordKey(it.password) } + .values + .filter { it.size > 1 } + .map { sharing -> + HealthFinding.Relation( + relatedItemIds = sharing.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused, + ) + }, ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt index 2a84d1ba1..b96225ced 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordChecker.kt @@ -1,8 +1,6 @@ package de.davis.keygo.feature.password_health.domain.checker import de.davis.keygo.core.item.domain.model.PasswordScore -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding @@ -14,17 +12,13 @@ import org.koin.core.annotation.Single internal class WeakPasswordChecker : PasswordHealthChecker { override val type = CheckKind.Strength - override suspend fun check( - candidates: List, - ): Result = Result.Success( - CheckOutcome( - findings = candidates.filter { it.score in WEAK_SCORES }.map { - HealthFinding.Item( - id = it.id, - issue = ItemIssue.Weak(score = it.score), - ) - }, - ), + override suspend fun check(candidates: List): CheckOutcome = CheckOutcome( + findings = candidates.filter { it.score in WEAK_SCORES }.map { + HealthFinding.Item( + id = it.id, + issue = ItemIssue.Weak(score = it.score), + ) + }, ) companion object { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt deleted file mode 100644 index 74a6d873d..000000000 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckError.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.davis.keygo.feature.password_health.domain.model - -sealed interface CheckError { - - data object Unreachable : CheckError - data object Failed : CheckError -} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt index 511fc55ab..b23758ee2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckGap.kt @@ -3,6 +3,6 @@ package de.davis.keygo.feature.password_health.domain.model import de.davis.keygo.core.item.domain.alias.ItemId data class CheckGap( - val error: CheckError, + val reason: GapReason, val unchecked: Set, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt index 11480c7b6..94fc93059 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/CheckOutcome.kt @@ -1,6 +1,17 @@ package de.davis.keygo.feature.password_health.domain.model data class CheckOutcome( - val findings: List, + val findings: List = emptyList(), val gap: CheckGap? = null, -) +) { + + companion object { + + fun skipped(reason: GapReason, candidates: List) = CheckOutcome( + gap = CheckGap( + reason = reason, + unchecked = candidates.mapTo(mutableSetOf()) { it.id }, + ), + ) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt new file mode 100644 index 000000000..a58117c90 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/GapReason.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface GapReason { + data object Disabled : GapReason + data object Unreachable : GapReason + data object Failed : GapReason +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index 053ced731..4d954d4d2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -45,4 +45,5 @@ data class PasswordHealthReport( val standalone: List, val totalPasswordsScanned: Int, val gaps: Map = emptyMap(), + val unreadable: Set = emptySet(), ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt index 5096bb213..f476f9582 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt @@ -2,4 +2,5 @@ package de.davis.keygo.feature.password_health.domain.model sealed interface PasswordHealthReportError { data object NoPasswords : PasswordHealthReportError + data object Unreadable : PasswordHealthReportError } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 6b89a97af..6632ab173 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -1,17 +1,18 @@ package de.davis.keygo.feature.password_health.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker -import de.davis.keygo.feature.password_health.domain.model.CheckError -import de.davis.keygo.feature.password_health.domain.model.CheckGap -import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate @@ -33,25 +34,23 @@ class PasswordHealthReportUseCase( suspend operator fun invoke(): Result = resultBinding { - val candidates = loginRepository.observeLogins() + val withPassword = loginRepository.observeLogins() .firstOrNull() - ?.mapNotNull { - it.passwordCredential?.let { credential -> - cryptographicScopeProvider.itemScope( - itemId = it.id, - ) { - PasswordCandidate( - id = it.id, - title = it.name, - username = it.username, - score = credential.score, - password = credential.secret.decrypt().toCharArray(), - ) - }.getOrNull() // TODO - } - } - .asResult(PasswordHealthReportError.NoPasswords) - .bind() + .orEmpty() + .mapNotNull { login -> login.passwordCredential?.let { login to it } } + + withPassword.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + + // A password that will not decrypt is checked by nobody, so it is reported instead + // of quietly dropping out of the count. + val unreadable = mutableSetOf() + val candidates = withPassword.mapNotNull { (login, credential) -> + candidate(login, credential) + .onFailure { unreadable += login.id } + .getOrNull() + } + + candidates.isNotEmpty().asResult(PasswordHealthReportError.Unreadable).bind() val outcomes = try { coroutineScope { @@ -61,11 +60,9 @@ class PasswordHealthReportUseCase( candidates.forEach { it.password.fill('\u0000') } } - val findings = outcomes.flatMap { (_, outcome) -> - outcome.getOrNull()?.findings.orEmpty() - } + val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } val gaps = outcomes.mapNotNull { (kind, outcome) -> - outcome.gap(candidates)?.let { kind to it } + outcome.gap?.let { kind to it } }.toMap() val issuesById = findings.filterIsInstance() @@ -111,19 +108,25 @@ class PasswordHealthReportUseCase( standalone = standalone, totalPasswordsScanned = candidates.size, gaps = gaps, + unreadable = unreadable, ) } -} -private fun Result.gap(candidates: List): CheckGap? = - when (this) { - is Result.Failure -> CheckGap( - error = error, - unchecked = candidates.mapTo(mutableSetOf()) { it.id }, + private suspend fun candidate( + login: Login, + credential: PasswordCredential, + ): Result = cryptographicScopeProvider.itemScope( + itemId = login.id, + ) { + PasswordCandidate( + id = login.id, + title = login.name, + username = login.username, + score = credential.score, + password = credential.secret.decrypt().toCharArray(), ) - - is Result.Success -> success.gap } +} private class UnionFind { private val parent = HashMap() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 52950212e..da04b173e 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -1,15 +1,16 @@ package de.davis.keygo.feature.password_health.presentation -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.fold import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -24,32 +25,53 @@ internal class PasswordHealthViewModel( private val _uiState = MutableStateFlow(PasswordHealthUiState(isLoading = true)) val uiState = _uiState.asStateFlow() + private var run: Job? = null + fun onEvent(event: PasswordHealthUiEvent) { when (event) { - PasswordHealthUiEvent.RunHealthCheck -> { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - val report = passwordHealth().getOrNull() // TODO - _uiState.update { - it.copy( - isLoading = false, - healthSections = report?.toSections().orEmpty(), - checkGaps = report?.gaps.orEmpty(), - totalPasswordCount = report?.totalPasswordsScanned ?: 0 - ) - } - - Log.d("PasswordHealthViewModel", "Report: $report") - } - } + PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck() PasswordHealthUiEvent.DismissGeneratePassword -> {} is PasswordHealthUiEvent.OnBreachCheckChanged -> {} is PasswordHealthUiEvent.PasswordGenerated -> {} } } + + private fun runHealthCheck() { + if (run?.isActive == true) return + + run = viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val report = passwordHealth() + _uiState.update { state -> + report.fold( + onSuccess = { state.withReport(it) }, + onFailure = { state.withError(it) }, + ) + } + } + } } +private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = copy( + isLoading = false, + error = null, + totalPasswordCount = report.totalPasswordsScanned, + healthSections = report.toSections(), + checkGaps = report.gaps, + unreadable = report.unreadable, +) + +private fun PasswordHealthUiState.withError(error: PasswordHealthReportError) = copy( + isLoading = false, + error = error, + totalPasswordCount = 0, + healthSections = emptyList(), + checkGaps = emptyMap(), + unreadable = emptySet(), +) + private fun PasswordHealthReport.toSections(): List { val groupsBySeverity = groups.groupBy { it.maxSeverity } val standaloneBySeverity = standalone.groupBy { requireNotNull(it.maxSeverity) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt index e30a9758e..c51e3f799 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -13,9 +13,9 @@ import androidx.compose.ui.tooling.preview.Preview import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.ui.components.KeyGoSwitch import de.davis.keygo.feature.password_health.R -import de.davis.keygo.feature.password_health.domain.model.CheckError import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.segmentContainerColor @@ -25,15 +25,16 @@ internal fun BreachCheck( onChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val gap = state.breachGap + val message = state.breachGap?.message() KeyGoSwitch( checked = state.breachCheckEnabled, onCheckedChange = onChange, modifier = modifier, supportingContent = { - if (gap == null) Text(text = stringResource(R.string.breach_check_opt_in_description)) - else Text(text = gap.message(), color = MaterialTheme.colorScheme.error) + if (message == null) + Text(text = stringResource(R.string.breach_check_opt_in_description)) + else Text(text = message, color = MaterialTheme.colorScheme.error) }, verticalAlignment = Alignment.CenterVertically, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), @@ -45,14 +46,17 @@ internal fun BreachCheck( @Composable @ReadOnlyComposable -private fun CheckGap.message(): String = pluralStringResource( - when (error) { - CheckError.Unreachable -> R.plurals.breach_check_unreachable - CheckError.Failed -> R.plurals.breach_check_failed - }, - unchecked.size, - unchecked.size, -) +private fun CheckGap.message(): String? { + val plural = when (reason) { + // An off switch is the whole explanation, so there is nothing to warn about. + GapReason.Disabled -> return null + + GapReason.Unreachable -> R.plurals.breach_check_unreachable + GapReason.Failed -> R.plurals.breach_check_failed + } + + return pluralStringResource(plural, unchecked.size, unchecked.size) +} @Preview @Composable @@ -73,7 +77,7 @@ private fun BreachCheckUnavailablePreview() { state = PasswordHealthUiState( checkGaps = mapOf( CheckKind.Breach to CheckGap( - error = CheckError.Unreachable, + reason = GapReason.Unreachable, unchecked = setOf(newItemId(), newItemId(), newItemId()), ), ), diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt index 0dda28fc7..b7b028d05 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -25,10 +25,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.coverageNote import de.davis.keygo.feature.password_health.presentation.model.detailLine import de.davis.keygo.feature.password_health.presentation.model.icon import de.davis.keygo.feature.password_health.presentation.model.toneColor @@ -77,20 +80,24 @@ internal fun PasswordHealthStatus( ) { Text(text = state.verdict()) - val detail = state.detailLine() + SupportingLine(text = state.detailLine(), label = "detailLineVisibility") + SupportingLine(text = state.coverageNote(), label = "coverageNoteVisibility") + } +} - AnimatedVisibility( - visible = detail != null, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - label = "detailLineVisibility" - ) { - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodySmall - ) - } +@Composable +private fun SupportingLine(text: String?, label: String) { + AnimatedVisibility( + visible = text != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + label = label + ) { + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall + ) } } } @@ -125,3 +132,29 @@ private fun AllGoodPreview() { } } } + +@Preview +@Composable +private fun UnavailablePreview() { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PasswordHealthStatus( + state = PasswordHealthUiState( + error = PasswordHealthReportError.Unreadable, + ), + ) + PasswordHealthStatus( + state = PasswordHealthUiState( + totalPasswordCount = 9, + unreadable = setOf(newItemId(), newItemId(), newItemId()), + ), + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 5b44914e9..95cf438cc 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.GppBad import androidx.compose.material.icons.filled.GppGood +import androidx.compose.material.icons.filled.GppMaybe import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -12,19 +13,22 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.model.RelatedGroup import de.davis.keygo.feature.password_health.domain.model.RelationType internal enum class PasswordHealthStatus { NO_DATA, ALL_GOOD, - NEEDS_ATTENTION + NEEDS_ATTENTION, + FAILED } data class HealthSummary( @@ -78,6 +82,8 @@ internal data class PasswordHealthUiState( val totalPasswordCount: Int = 0, val healthSections: List = emptyList(), val checkGaps: Map = emptyMap(), + val unreadable: Set = emptySet(), + val error: PasswordHealthReportError? = null, val generatePassword: Boolean = false, ) { val summary by lazy { healthSections.summary() } @@ -85,6 +91,9 @@ internal data class PasswordHealthUiState( val breachGap: CheckGap? = checkGaps[CheckKind.Breach] val status = when { + // An empty vault is not a failure, it just has nothing to say yet. + error == PasswordHealthReportError.NoPasswords -> PasswordHealthStatus.NO_DATA + error != null -> PasswordHealthStatus.FAILED totalPasswordCount == 0 -> PasswordHealthStatus.NO_DATA healthSections.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION else -> PasswordHealthStatus.ALL_GOOD @@ -98,7 +107,8 @@ internal fun PasswordHealthUiState.toneColor(): Color { return when (status) { PasswordHealthStatus.NO_DATA -> MaterialTheme.colorScheme.surfaceContainerHigh PasswordHealthStatus.ALL_GOOD -> MaterialTheme.colorScheme.secondaryContainer - PasswordHealthStatus.NEEDS_ATTENTION -> MaterialTheme.colorScheme.errorContainer + PasswordHealthStatus.NEEDS_ATTENTION, + PasswordHealthStatus.FAILED -> MaterialTheme.colorScheme.errorContainer } } @@ -108,6 +118,7 @@ internal fun PasswordHealthStatus.icon() = when (this) { PasswordHealthStatus.NO_DATA -> Icons.Default.HealthAndSafety PasswordHealthStatus.ALL_GOOD -> Icons.Default.GppGood PasswordHealthStatus.NEEDS_ATTENTION -> Icons.Default.GppBad + PasswordHealthStatus.FAILED -> Icons.Default.GppMaybe } @Composable @@ -118,7 +129,8 @@ internal fun PasswordHealthUiState.verdict(): String { PasswordHealthStatus.NO_DATA -> stringResource(R.string.password_health_no_data) PasswordHealthStatus.ALL_GOOD -> - if (checkGaps.isEmpty()) stringResource(R.string.password_health_all_good) + if (checkGaps.isEmpty() && unreadable.isEmpty()) + stringResource(R.string.password_health_all_good) else stringResource(R.string.password_health_checked_all_good) PasswordHealthStatus.NEEDS_ATTENTION -> pluralStringResource( @@ -126,6 +138,8 @@ internal fun PasswordHealthUiState.verdict(): String { summary.needsAttention, summary.needsAttention ) + + PasswordHealthStatus.FAILED -> error.failureMessage() } } @@ -136,6 +150,7 @@ internal fun PasswordHealthUiState.detailLine(): String? { return when (status) { PasswordHealthStatus.NO_DATA -> null + PasswordHealthStatus.FAILED -> stringResource(R.string.password_health_retry_hint) PasswordHealthStatus.ALL_GOOD -> pluralStringResource( R.plurals.detail_line_checked, totalPasswordCount, @@ -154,6 +169,26 @@ internal fun PasswordHealthUiState.detailLine(): String? { } } +@Composable +@ReadOnlyComposable +internal fun PasswordHealthUiState.coverageNote(): String? { + if (isLoading || unreadable.isEmpty()) return null + + return pluralStringResource( + R.plurals.password_health_unreadable_count, + unreadable.size, + unreadable.size, + ) +} + +@Composable +@ReadOnlyComposable +private fun PasswordHealthReportError?.failureMessage(): String = when (this) { + PasswordHealthReportError.Unreadable -> stringResource(R.string.password_health_unreadable) + PasswordHealthReportError.NoPasswords, null -> + stringResource(R.string.password_health_check_failed) +} + @Composable @ReadOnlyComposable private fun countLabel(@StringRes id: Int, count: Int): String? = diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 9c6351d12..d107efbf4 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -11,6 +11,13 @@ %1$d passwords need attention Checking your passwords… + Couldn\'t check your passwords + None of your passwords could be opened for checking + Pull down to try again + + %1$d password couldn\'t be opened and went unchecked + %1$d passwords couldn\'t be opened and went unchecked + %1$d password checked From 49e62041286af661a825e5b6ad6b9fb92236767b Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 12:41:59 +0200 Subject: [PATCH 12/54] refactor: loading logic --- .../presentation/PasswordHealthContent.kt | 4 ++-- .../presentation/PasswordHealthViewModel.kt | 14 ++++++++------ .../component/PasswordHealthStatus.kt | 5 +++-- .../model/PasswordHealthUiEvent.kt | 1 + .../model/PasswordHealthUiState.kt | 19 ++++++++++++++----- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index 73e259bed..947004dff 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -60,8 +60,8 @@ internal fun PasswordHealthContent( } ) { innerPadding -> PullToRefreshBox( - isRefreshing = state.isLoading, - onRefresh = { onEvent(PasswordHealthUiEvent.RunHealthCheck) }, + isRefreshing = state.isRefreshing, + onRefresh = { onEvent(PasswordHealthUiEvent.RefreshHealthCheck) }, modifier = Modifier .fillMaxSize() .padding(innerPadding) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index da04b173e..e6132c469 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -10,6 +10,7 @@ import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthRepor import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,14 +23,15 @@ internal class PasswordHealthViewModel( private val passwordHealth: PasswordHealthReportUseCase ) : ViewModel() { - private val _uiState = MutableStateFlow(PasswordHealthUiState(isLoading = true)) + private val _uiState = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) val uiState = _uiState.asStateFlow() private var run: Job? = null fun onEvent(event: PasswordHealthUiEvent) { when (event) { - PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck() + PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) + PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck(RunPhase.Refresh) PasswordHealthUiEvent.DismissGeneratePassword -> {} is PasswordHealthUiEvent.OnBreachCheckChanged -> {} @@ -37,11 +39,11 @@ internal class PasswordHealthViewModel( } } - private fun runHealthCheck() { + private fun runHealthCheck(phase: RunPhase) { if (run?.isActive == true) return run = viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(phase = phase) } val report = passwordHealth() _uiState.update { state -> @@ -55,7 +57,7 @@ internal class PasswordHealthViewModel( } private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = copy( - isLoading = false, + phase = RunPhase.Idle, error = null, totalPasswordCount = report.totalPasswordsScanned, healthSections = report.toSections(), @@ -64,7 +66,7 @@ private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = cop ) private fun PasswordHealthUiState.withError(error: PasswordHealthReportError) = copy( - isLoading = false, + phase = RunPhase.Idle, error = error, totalPasswordCount = 0, healthSections = emptyList(), diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt index b7b028d05..11daddfc4 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -31,6 +31,7 @@ import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase import de.davis.keygo.feature.password_health.presentation.model.coverageNote import de.davis.keygo.feature.password_health.presentation.model.detailLine import de.davis.keygo.feature.password_health.presentation.model.icon @@ -64,7 +65,7 @@ internal fun PasswordHealthStatus( contentColor = contentColor ), leadingItem = { - AnimatedContent(state.isLoading) { + AnimatedContent(state.isFirstLoad) { when (it) { true -> CircularWavyProgressIndicator( modifier = Modifier.size(40.dp) @@ -115,7 +116,7 @@ private fun AllGoodPreview() { PasswordHealthStatus(state = PasswordHealthUiState()) PasswordHealthStatus( state = PasswordHealthUiState( - isLoading = true + phase = RunPhase.FirstLoad ) ) PasswordHealthStatus( diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt index f097178cd..23456760a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.password_health.presentation.model internal sealed interface PasswordHealthUiEvent { data object RunHealthCheck : PasswordHealthUiEvent + data object RefreshHealthCheck : PasswordHealthUiEvent data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 95cf438cc..ca44d3c73 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -24,6 +24,12 @@ import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportE import de.davis.keygo.feature.password_health.domain.model.RelatedGroup import de.davis.keygo.feature.password_health.domain.model.RelationType +internal enum class RunPhase { + Idle, + FirstLoad, + Refresh, +} + internal enum class PasswordHealthStatus { NO_DATA, ALL_GOOD, @@ -77,7 +83,7 @@ internal data class HealthSection( @Stable internal data class PasswordHealthUiState( - val isLoading: Boolean = false, + val phase: RunPhase = RunPhase.Idle, val breachCheckEnabled: Boolean = false, val totalPasswordCount: Int = 0, val healthSections: List = emptyList(), @@ -86,6 +92,9 @@ internal data class PasswordHealthUiState( val error: PasswordHealthReportError? = null, val generatePassword: Boolean = false, ) { + val isFirstLoad = phase == RunPhase.FirstLoad + val isRefreshing = phase == RunPhase.Refresh + val summary by lazy { healthSections.summary() } val breachGap: CheckGap? = checkGaps[CheckKind.Breach] @@ -103,7 +112,7 @@ internal data class PasswordHealthUiState( @Composable @ReadOnlyComposable internal fun PasswordHealthUiState.toneColor(): Color { - if (isLoading) return MaterialTheme.colorScheme.surfaceContainerHigh + if (isFirstLoad) return MaterialTheme.colorScheme.surfaceContainerHigh return when (status) { PasswordHealthStatus.NO_DATA -> MaterialTheme.colorScheme.surfaceContainerHigh PasswordHealthStatus.ALL_GOOD -> MaterialTheme.colorScheme.secondaryContainer @@ -124,7 +133,7 @@ internal fun PasswordHealthStatus.icon() = when (this) { @Composable @ReadOnlyComposable internal fun PasswordHealthUiState.verdict(): String { - if (isLoading) return stringResource(R.string.checking_passwords) + if (isFirstLoad) return stringResource(R.string.checking_passwords) return when (status) { PasswordHealthStatus.NO_DATA -> stringResource(R.string.password_health_no_data) @@ -146,7 +155,7 @@ internal fun PasswordHealthUiState.verdict(): String { @Composable @ReadOnlyComposable internal fun PasswordHealthUiState.detailLine(): String? { - if (isLoading) return null + if (isFirstLoad) return null return when (status) { PasswordHealthStatus.NO_DATA -> null @@ -172,7 +181,7 @@ internal fun PasswordHealthUiState.detailLine(): String? { @Composable @ReadOnlyComposable internal fun PasswordHealthUiState.coverageNote(): String? { - if (isLoading || unreadable.isEmpty()) return null + if (isFirstLoad || unreadable.isEmpty()) return null return pluralStringResource( R.plurals.password_health_unreadable_count, From bdc810a7c4d890d22d1f63f95710fd6cd47eccab Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 13:05:22 +0200 Subject: [PATCH 13/54] feat: wire up breach switch --- .../BreachCheckStateRepositoryImpl.kt | 5 +++ .../repository/BreachCheckStateRepository.kt | 2 ++ .../presentation/PasswordHealthViewModel.kt | 31 ++++++++++++++----- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt index b40bb27ec..01c918506 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt @@ -6,7 +6,9 @@ import de.davis.keygo.feature.password_health.data.mapper.toDomain import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier import de.davis.keygo.feature.password_health.domain.model.BreachCheckState import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single @@ -15,6 +17,9 @@ internal class BreachCheckStateRepositoryImpl( private val dataStore: DataStore, ) : BreachCheckStateRepository { + override fun observeBreachCheckState(): Flow = dataStore.data + .map(ProtoBreachCheckState::toDomain) + override suspend fun getBreachCheckState(): BreachCheckState = dataStore.data.first().toDomain() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt index 3e3b3b75a..50d7fa43c 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt @@ -1,8 +1,10 @@ package de.davis.keygo.feature.password_health.domain.repository import de.davis.keygo.feature.password_health.domain.model.BreachCheckState +import kotlinx.coroutines.flow.Flow interface BreachCheckStateRepository { + fun observeBreachCheckState(): Flow suspend fun getBreachCheckState(): BreachCheckState suspend fun setBreachEnabled(enabled: Boolean) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index e6132c469..0099b2840 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.util.fold import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent @@ -13,18 +14,30 @@ import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthU import de.davis.keygo.feature.password_health.presentation.model.RunPhase import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class PasswordHealthViewModel( - private val passwordHealth: PasswordHealthReportUseCase + private val passwordHealth: PasswordHealthReportUseCase, + private val breachCheckStateRepository: BreachCheckStateRepository, ) : ViewModel() { - private val _uiState = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) - val uiState = _uiState.asStateFlow() + private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) + val uiState = combine( + _base, + breachCheckStateRepository.observeBreachCheckState(), + ) { base, breachCheckState -> + base.copy(breachCheckEnabled = breachCheckState.enabled) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = _base.value + ) private var run: Job? = null @@ -33,8 +46,12 @@ internal class PasswordHealthViewModel( PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck(RunPhase.Refresh) + is PasswordHealthUiEvent.OnBreachCheckChanged -> viewModelScope.launch { + breachCheckStateRepository.setBreachEnabled(event.enabled) + if (event.enabled) runHealthCheck(RunPhase.FirstLoad) + } + PasswordHealthUiEvent.DismissGeneratePassword -> {} - is PasswordHealthUiEvent.OnBreachCheckChanged -> {} is PasswordHealthUiEvent.PasswordGenerated -> {} } } @@ -43,10 +60,10 @@ internal class PasswordHealthViewModel( if (run?.isActive == true) return run = viewModelScope.launch { - _uiState.update { it.copy(phase = phase) } + _base.update { it.copy(phase = phase) } val report = passwordHealth() - _uiState.update { state -> + _base.update { state -> report.fold( onSuccess = { state.withReport(it) }, onFailure = { state.withError(it) }, From 66f5953d1b12582a542d7bf692c492eb697e9c8d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 20 Sep 2026 18:11:21 +0200 Subject: [PATCH 14/54] feat: add fix password feature --- feature/password-health/build.gradle.kts | 2 + .../domain/model/PasswordCandidate.kt | 1 + .../domain/model/PasswordFixError.kt | 6 + .../domain/model/PasswordHealthReport.kt | 3 +- .../usecase/PasswordHealthReportUseCase.kt | 2 + .../presentation/PasswordHealthContent.kt | 25 +- .../presentation/PasswordHealthScreen.kt | 2 +- .../presentation/PasswordHealthViewModel.kt | 77 +++- .../presentation/component/ItemHealthRow.kt | 411 ++++++++++++++++++ .../component/NeedsAttentionSection.kt | 70 +-- .../presentation/model/FixFlow.kt | 19 + .../model/PasswordHealthUiEvent.kt | 13 +- .../model/PasswordHealthUiState.kt | 33 +- .../src/main/res/values/strings.xml | 8 + 14 files changed, 600 insertions(+), 72 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 5c109fecd..3360889e2 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -17,7 +17,9 @@ dependencies { implementation(projects.core.item) implementation(projects.core.util) implementation(projects.core.security) + implementation(projects.feature.item.core) implementation(projects.feature.item.create) + implementation(projects.feature.item.view) implementation(libs.okhttp) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt index f9b71d52f..5c863f1d2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt @@ -9,4 +9,5 @@ class PasswordCandidate( val username: String?, val score: PasswordScore, val password: CharArray, + val urls: List, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt new file mode 100644 index 000000000..1a91e99fd --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordFixError.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +enum class PasswordFixError { + Locked, + Save, +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index 4d954d4d2..0d10327b8 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -6,7 +6,8 @@ data class ItemHealth( val itemId: ItemId, val title: String, val username: String?, - val issues: List + val issues: List, + val urls: List = emptyList(), ) { val maxSeverity: FindingSeverity? = issues.maxOfOrNull { it.severity } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 6632ab173..39bf70f1a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -81,6 +81,7 @@ class PasswordHealthReportUseCase( title = item.title, username = item.username, issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, + urls = item.urls, ) val (related, unrelated) = candidates.partition { uf.contains(it.id) } @@ -124,6 +125,7 @@ class PasswordHealthReportUseCase( username = login.username, score = credential.score, password = credential.secret.decrypt().toCharArray(), + urls = login.domainInfos.map { it.value }, ) } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index 947004dff..3dc1ed43c 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -17,14 +17,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.ui.clipboard.setText import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.FindingSeverity @@ -38,6 +41,7 @@ import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthS import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.verdict +import kotlinx.coroutines.launch @Composable internal fun PasswordHealthContent( @@ -45,6 +49,9 @@ internal fun PasswordHealthContent( onEvent: (PasswordHealthUiEvent) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copiedLabel = stringResource(R.string.fix_copied_label) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -78,8 +85,11 @@ internal fun PasswordHealthContent( PasswordHealthStatus(state = state, modifier = Modifier.animateItem()) } - if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) - needsAttentionSection(sections = state.healthSections) + if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) needsAttentionSection( + sections = state.healthSections, + pendingFix = state.pendingFix, + onEvent = onEvent, + ) item(key = "breach_check") { Spacer(modifier = Modifier.height(28.dp)) @@ -93,8 +103,13 @@ internal fun PasswordHealthContent( } } - if (state.generatePassword) GeneratePasswordModalBottomSheet( - onGenerated = { onEvent(PasswordHealthUiEvent.PasswordGenerated(it)) }, + if (state.generatingFix != null) GeneratePasswordModalBottomSheet( + onGenerated = { password -> + scope.launch { + clipboard.setText(label = copiedLabel, text = password, sensitive = true) + } + onEvent(PasswordHealthUiEvent.PasswordGenerated(password)) + }, onDismiss = { onEvent(PasswordHealthUiEvent.DismissGeneratePassword) }, ) } @@ -109,7 +124,7 @@ private fun PasswordHealthContentPreview() { PasswordHealthContent( state = PasswordHealthUiState( totalPasswordCount = 12, - healthSections = listOf( + reportedSections = listOf( HealthSection( severity = FindingSeverity.Medium, groups = emptyList(), diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt index c9fea67cc..6c98c1fa4 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -20,4 +20,4 @@ fun PasswordHealthScreen() { state = state, onEvent = viewModel::onEvent, ) -} \ No newline at end of file +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 0099b2840..eb1dc7417 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -3,11 +3,18 @@ package de.davis.keygo.feature.password_health.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.util.fold +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertLogin +import de.davis.keygo.feature.item.core.domain.model.set +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.item.view.domain.WebsiteHandler import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState @@ -24,7 +31,9 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class PasswordHealthViewModel( private val passwordHealth: PasswordHealthReportUseCase, + private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, private val breachCheckStateRepository: BreachCheckStateRepository, + private val websiteHandler: WebsiteHandler, ) : ViewModel() { private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) @@ -51,13 +60,67 @@ internal class PasswordHealthViewModel( if (event.enabled) runHealthCheck(RunPhase.FirstLoad) } - PasswordHealthUiEvent.DismissGeneratePassword -> {} - is PasswordHealthUiEvent.PasswordGenerated -> {} + is PasswordHealthUiEvent.FixClicked -> + _base.update { it.copy(fixFlow = FixFlow.Generating(event.itemId)) } + + is PasswordHealthUiEvent.PasswordGenerated -> _base.update { state -> + val target = state.fixFlow ?: return@update state + state.copy( + fixFlow = FixFlow.Pending(itemId = target.itemId, password = event.password), + ) + } + + PasswordHealthUiEvent.DismissGeneratePassword -> _base.update { + if (it.fixFlow is FixFlow.Generating) it.copy(fixFlow = null) else it + } + + is PasswordHealthUiEvent.OpenSite -> websiteHandler.openWebsite(event.url) + + PasswordHealthUiEvent.DiscardFix -> _base.update { it.copy(fixFlow = null) } + + PasswordHealthUiEvent.ConfirmPasswordChanged -> applyPendingFix() } } - private fun runHealthCheck(phase: RunPhase) { - if (run?.isActive == true) return + private fun applyPendingFix() { + val pending = _base.value.pendingFix ?: return + if (pending.applying) return + + viewModelScope.launch { + _base.update { + it.copy( + fixFlow = pending.copy(applying = true, error = null), + optimisticallyFixed = it.optimisticallyFixed + pending.itemId, + ) + } + + createNewOrUpdateLogin( + UpsertLogin.update(itemId = pending.itemId, password = set(pending.password)), + ).fold( + onSuccess = { + _base.update { it.copy(fixFlow = null) } + runHealthCheck(RunPhase.Refresh, restartInFlight = true) + }, + onFailure = { errors -> + val error = + if (errors.any { it is ItemUpsertError.CryptoError }) PasswordFixError.Locked + else PasswordFixError.Save + _base.update { + it.copy( + fixFlow = pending.copy(applying = false, error = error), + optimisticallyFixed = it.optimisticallyFixed - pending.itemId, + ) + } + }, + ) + } + } + + private fun runHealthCheck(phase: RunPhase, restartInFlight: Boolean = false) { + if (run?.isActive == true) { + if (!restartInFlight) return + run?.cancel() + } run = viewModelScope.launch { _base.update { it.copy(phase = phase) } @@ -77,18 +140,20 @@ private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = cop phase = RunPhase.Idle, error = null, totalPasswordCount = report.totalPasswordsScanned, - healthSections = report.toSections(), + reportedSections = report.toSections(), checkGaps = report.gaps, unreadable = report.unreadable, + optimisticallyFixed = emptySet(), ) private fun PasswordHealthUiState.withError(error: PasswordHealthReportError) = copy( phase = RunPhase.Idle, error = error, totalPasswordCount = 0, - healthSections = emptyList(), + reportedSections = emptyList(), checkGaps = emptyMap(), unreadable = emptySet(), + optimisticallyFixed = emptySet(), ) private fun PasswordHealthReport.toSections(): List { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt new file mode 100644 index 000000000..c978a4566 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt @@ -0,0 +1,411 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenu +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +@Composable +internal fun ItemHealthRow( + itemHealth: ItemHealth, + shapes: ListItemShapes, + pendingFix: FixFlow.Pending?, + onEvent: (PasswordHealthUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val breach = itemHealth.breach + val weak = itemHealth.weak + val username = itemHealth.username + + SegmentedListItem( + shapes = shapes, + modifier = modifier.fillMaxWidth(), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + supportingContent = if (pendingFix != null || username != null || weak != null) { + { + AnimatedContent( + targetState = pendingFix, + modifier = Modifier + .fillMaxWidth() + .animateHeight(), + contentKey = { it != null }, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn( + tween(FadeInMillis, delayMillis = FadeOutMillis), + ), + initialContentExit = fadeOut(tween(FadeOutMillis)), + // A size transform animates width and height together, and the width + // it reports trails the width the row just measured the content with, + // so the content ends up wider than the box it is drawn in and gets + // clipped. Leave the width to the layout, which hands over exactly the + // space the trailing button has freed, and animate the height alone. + sizeTransform = null, + ) + }, + label = "fix-details", + ) { pending -> + if (pending != null) PendingFixActions( + urls = itemHealth.urls, + pending = pending, + onEvent = onEvent, + ) else FlaggedDetails(username = username, weak = weak) + } + } + } else null, + trailingContent = { + AnimatedVisibility( + visible = pendingFix == null, + enter = expandHorizontally(tween(CollapseMillis), expandFrom = Alignment.End) + + fadeIn(tween(FadeInMillis, delayMillis = FadeOutMillis)), + exit = shrinkHorizontally(tween(CollapseMillis), shrinkTowards = Alignment.End) + + fadeOut(tween(FadeOutMillis)), + ) { + IconButton( + onClick = { onEvent(PasswordHealthUiEvent.FixClicked(itemHealth.itemId)) }, + ) { + Icon(imageVector = Icons.Default.AutoFixHigh, contentDescription = null) + } + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + ItemHealthTitle( + title = itemHealth.title, + breach = breach, + inlineBreach = pendingFix != null, + ) + } +} + +@Composable +private fun ItemHealthTitle( + title: String, + breach: ItemIssue.Breached?, + inlineBreach: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + LookaheadScope { + val badge = remember(breach) { + breach?.let { breached -> + movableContentOf { + Text( + text = pluralStringResource( + R.plurals.needs_attention_found_in_breach, + breached.occurrences, + breached.occurrences, + ), + modifier = Modifier.animateBounds(this@LookaheadScope), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Column( + modifier = Modifier.animateBounds(this@LookaheadScope), + verticalArrangement = Arrangement.spacedBy(TitleLineSpacing), + ) { + if (badge != null && !inlineBreach) badge() + + Row( + horizontalArrangement = Arrangement.spacedBy(TitleBadgeSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f, fill = false) + .animateBounds(this@LookaheadScope), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (badge != null && inlineBreach) badge() + } + } + } + } +} + +@Composable +private fun Modifier.animateHeight(): Modifier { + var contentHeight by remember { mutableIntStateOf(UnmeasuredHeight) } + val animatedHeight = remember { Animatable(UnmeasuredHeight, Int.VectorConverter) } + + LaunchedEffect(contentHeight) { + if (contentHeight == UnmeasuredHeight) return@LaunchedEffect + + if (animatedHeight.value == UnmeasuredHeight) animatedHeight.snapTo(contentHeight) + else animatedHeight.animateTo(contentHeight, tween(ResizeMillis)) + } + + return clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity), + ) + val height = animatedHeight.value + + layout( + width = placeable.width, + height = if (height == UnmeasuredHeight) placeable.height else height, + ) { + placeable.place(0, 0) + } + } + .onSizeChanged { contentHeight = it.height } +} + +@Composable +private fun FlaggedDetails( + username: String?, + weak: ItemIssue.Weak?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (username != null) Text( + text = username, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (weak != null) StrengthIndicator(passwordScore = weak.score) + } +} + +@Composable +private fun PendingFixActions( + urls: List, + pending: FixFlow.Pending, + onEvent: (PasswordHealthUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val enabled = !pending.applying + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = stringResource(R.string.fix_pending_instruction)) + + if (pending.error != null) Text( + text = pending.error.message, + color = MaterialTheme.colorScheme.error, + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton( + onClick = { onEvent(PasswordHealthUiEvent.DiscardFix) }, + enabled = enabled, + ) { + Text(text = stringResource(R.string.fix_cancel)) + } + + if (urls.isNotEmpty()) OpenSiteAction( + urls = urls, + enabled = enabled, + onOpen = { onEvent(PasswordHealthUiEvent.OpenSite(it)) }, + ) + + Button( + onClick = { onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) }, + enabled = enabled, + ) { + Text(text = stringResource(R.string.fix_confirm_changed)) + } + } + } +} + +@Composable +private fun OpenSiteAction( + urls: List, + enabled: Boolean, + onOpen: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val chevronRotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") + val single = urls.singleOrNull() + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextButton( + onClick = { if (single != null) onOpen(single) else expanded = true }, + enabled = enabled, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.fix_open_site), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (single == null) { + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + .graphicsLayer { rotationZ = chevronRotation }, + ) + } + } + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MenuDefaults.groupStandardContainerColor, + shape = MenuDefaults.standaloneGroupShape, + ) { + urls.forEachIndexed { index, url -> + DropdownMenuItem( + text = { Text(text = url) }, + onClick = { + expanded = false + onOpen(url) + }, + shape = MenuDefaults.itemShape(index, urls.size).shape, + ) + } + } + } +} + +private const val FadeOutMillis = 90 +private const val FadeInMillis = 220 +private const val CollapseMillis = 150 +private const val ResizeMillis = 250 +private const val UnmeasuredHeight = -1 + +private val TitleLineSpacing = 2.dp +private val TitleBadgeSpacing = 8.dp + +private val PasswordFixError.message: String + @Composable + get() = stringResource( + when (this) { + PasswordFixError.Locked -> R.string.fix_failed_locked + PasswordFixError.Save -> R.string.fix_failed_save + } + ) + +@Preview +@Composable +private fun ItemHealthRowPreview() { + val itemId = newItemId() + + MaterialTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + ItemHealthRow( + itemHealth = ItemHealth( + itemId = newItemId(), + title = "Old forum account", + username = "user@example.com", + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + shapes = ListItemDefaults.shapes(MaterialTheme.shapes.large), + pendingFix = null, + onEvent = {}, + ) + + ItemHealthRow( + itemHealth = ItemHealth( + itemId = itemId, + title = "GitHub", + username = "user@example.com", + issues = listOf(ItemIssue.Breached(occurrences = 4)), + urls = listOf("github.com", "gist.github.com"), + ), + shapes = ListItemDefaults.shapes(MaterialTheme.shapes.large), + pendingFix = FixFlow.Pending( + itemId = itemId, + password = "correct-horse-battery-staple", + ), + onEvent = {}, + ) + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt index 51715a242..9a198bb22 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -2,10 +2,8 @@ package de.davis.keygo.feature.password_health.presentation.component import androidx.annotation.StringRes 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -14,20 +12,16 @@ import androidx.compose.foundation.lazy.itemsIndexed 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.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore -import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.HealthFinding @@ -35,11 +29,14 @@ import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.RelatedGroup import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection -import de.davis.keygo.feature.password_health.presentation.segmentContainerColor +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent internal fun LazyListScope.needsAttentionSection( sections: List, + pendingFix: FixFlow.Pending?, + onEvent: (PasswordHealthUiEvent) -> Unit, ) { item(key = "needs-attention-title", contentType = ContentType.Title) { Spacer(modifier = Modifier.height(28.dp)) @@ -69,14 +66,18 @@ internal fun LazyListScope.needsAttentionSection( GroupLabel(group = group, modifier = Modifier.animateItem()) } - segmentedRows(group.orderedMembers) + segmentedRows(group.orderedMembers, pendingFix, onEvent) } - segmentedRows(section.standalone) + segmentedRows(section.standalone, pendingFix, onEvent) } } -private fun LazyListScope.segmentedRows(items: List) { +private fun LazyListScope.segmentedRows( + items: List, + pendingFix: FixFlow.Pending?, + onEvent: (PasswordHealthUiEvent) -> Unit, +) { itemsIndexed( items = items, key = { _, item -> item.itemId }, @@ -85,6 +86,8 @@ private fun LazyListScope.segmentedRows(items: List) { ItemHealthRow( itemHealth = item, shapes = segmentedShapesFor(index = index, count = items.size), + pendingFix = pendingFix?.takeIf { it.itemId == item.itemId }, + onEvent = onEvent, modifier = Modifier.animateItem(), ) } @@ -109,51 +112,6 @@ private fun GroupLabel( ) } -@Composable -private fun ItemHealthRow( - itemHealth: ItemHealth, - shapes: ListItemShapes, - modifier: Modifier = Modifier, -) { - val breach = itemHealth.breach - val weak = itemHealth.weak - val username = itemHealth.username - - SegmentedListItem( - shapes = shapes, - modifier = modifier.fillMaxWidth(), - colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), - overlineContent = breach?.let { breached -> - { - Text( - text = pluralStringResource( - R.plurals.needs_attention_found_in_breach, - breached.occurrences, - breached.occurrences, - ), - color = MaterialTheme.colorScheme.error, - ) - } - }, - supportingContent = if (username != null || weak != null) { - { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (username != null) Text( - text = username, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (weak != null) StrengthIndicator(passwordScore = weak.score) - } - } - } else null, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = itemHealth.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - @Composable private fun segmentedShapesFor(index: Int, count: Int): ListItemShapes = if (count == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) @@ -195,6 +153,8 @@ private fun NeedsAttentionSectionPreview() { verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) ) { needsAttentionSection( + pendingFix = null, + onEvent = {}, sections = listOf( HealthSection( severity = FindingSeverity.Critical, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt new file mode 100644 index 000000000..d2d73fd62 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/FixFlow.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError + +@Stable +internal sealed interface FixFlow { + val itemId: ItemId + + data class Generating(override val itemId: ItemId) : FixFlow + + data class Pending( + override val itemId: ItemId, + val password: String, + val applying: Boolean = false, + val error: PasswordFixError? = null, + ) : FixFlow +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt index 23456760a..bee41bc22 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -1,11 +1,20 @@ package de.davis.keygo.feature.password_health.presentation.model +import de.davis.keygo.core.item.domain.alias.ItemId + internal sealed interface PasswordHealthUiEvent { data object RunHealthCheck : PasswordHealthUiEvent data object RefreshHealthCheck : PasswordHealthUiEvent - + data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent + data class FixClicked(val itemId: ItemId) : PasswordHealthUiEvent data class PasswordGenerated(val password: String) : PasswordHealthUiEvent data object DismissGeneratePassword : PasswordHealthUiEvent -} \ No newline at end of file + + data class OpenSite(val url: String) : PasswordHealthUiEvent + + data object ConfirmPasswordChanged : PasswordHealthUiEvent + + data object DiscardFix : PasswordHealthUiEvent +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index ca44d3c73..aed48c631 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -81,20 +81,49 @@ internal data class HealthSection( val standalone: List ) +internal fun List.withoutFixed(fixed: Set): List { + if (fixed.isEmpty()) return this + + return mapNotNull { section -> + val groups = mutableListOf() + val unrelated = mutableListOf() + + section.groups.forEach { group -> + val members = group.members.filterNot { it.itemId in fixed } + if (members.size >= MinimumGroupSize) groups += group.copy(members = members.toSet()) + else unrelated += members.filter { it.issues.isNotEmpty() } + } + + val standalone = (section.standalone.filterNot { it.itemId in fixed } + unrelated) + .sortedByDescending { it.maxSeverity } + + if (groups.isEmpty() && standalone.isEmpty()) null + else section.copy(groups = groups, standalone = standalone) + } +} + +private const val MinimumGroupSize = 2 + @Stable internal data class PasswordHealthUiState( val phase: RunPhase = RunPhase.Idle, val breachCheckEnabled: Boolean = false, val totalPasswordCount: Int = 0, - val healthSections: List = emptyList(), + val reportedSections: List = emptyList(), val checkGaps: Map = emptyMap(), val unreadable: Set = emptySet(), val error: PasswordHealthReportError? = null, - val generatePassword: Boolean = false, + val fixFlow: FixFlow? = null, + val optimisticallyFixed: Set = emptySet(), ) { val isFirstLoad = phase == RunPhase.FirstLoad val isRefreshing = phase == RunPhase.Refresh + val generatingFix = fixFlow as? FixFlow.Generating + val pendingFix = fixFlow as? FixFlow.Pending + + val healthSections = reportedSections.withoutFixed(optimisticallyFixed) + val summary by lazy { healthSections.summary() } val breachGap: CheckGap? = checkGaps[CheckKind.Breach] diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index d107efbf4..ad19b0f82 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -62,4 +62,12 @@ %1$d password went unchecked: the breach service didn\'t answer %1$d passwords went unchecked: the breach service didn\'t answer + + Generated password + New password copied. Change it where this login is used, then confirm here. + Open site + I changed it + Cancel + Your session ended before it could be saved. Try again. + Couldn\'t save the new password. Try again. From 33dd393aa1a42f191856b8e7712c07865ce893e6 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 21 Sep 2026 16:02:11 +0200 Subject: [PATCH 15/54] feat: add similarity checker --- feature/password-health/build.gradle.kts | 1 + .../domain/checker/PasswordSimilarity.kt | 394 +++++++++++ .../domain/checker/SimilarPasswordChecker.kt | 310 +++++++++ .../domain/checker/WordModel.kt | 262 +++++++ .../domain/checker/CheckerFixtures.kt | 35 + .../domain/checker/PassphraseNoiseTest.kt | 115 ++++ .../SimilarPasswordCheckerBenchmark.kt | 478 +++++++++++++ .../checker/SimilarPasswordCheckerTest.kt | 254 +++++++ .../domain/checker/SimilarityCorpus.kt | 638 ++++++++++++++++++ .../domain/checker/SimilarityCorpusTest.kt | 82 +++ .../checker/SimilaritySensitivitySweep.kt | 266 ++++++++ .../domain/checker/TunableSimilarity.kt | 207 ++++++ .../domain/checker/WordModelTest.kt | 154 +++++ .../domain/model/RelatedGroupTest.kt | 68 ++ 14 files changed, 3264 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 3360889e2..9fd8732fb 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.feature.item.view) implementation(libs.okhttp) + implementation(libs.gosimple.nbvcxz) implementation(libs.androidx.datastore) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt new file mode 100644 index 000000000..092adb6e5 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/PasswordSimilarity.kt @@ -0,0 +1,394 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.FREE +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.SECRET +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.SEPARATOR +import de.davis.keygo.feature.password_health.domain.checker.PasswordShape.Companion.WORD +import kotlin.math.abs + +private val LEET_CLASSES = mapOf( + 'i' to setOf('l', '1', '|', '!'), + 'o' to setOf('0'), + 'a' to setOf('@', '4'), + 'e' to setOf('3'), + 's' to setOf('5', '$'), + 't' to setOf('7', '+'), + 'b' to setOf('8'), + 'g' to setOf('9'), +) + +private val LEET_FOLD = LEET_CLASSES.flatMap { (key, values) -> values.map { it to key } }.toMap() + +internal fun CharArray.canonicalize(): CharArray = CharArray(size) { index -> + val lower = this[index].lowercaseChar() + LEET_FOLD[lower] ?: lower +} + +private const val WORD_SEPARATORS = "-_. " + +/** Letters in a row that read as a word even when no list has it: a site, a surname. */ +private const val WORD_RUN = 4 + +/** Digits in a row short enough to be a counter rather than a secret. */ +private const val SHORT_DIGIT_RUN = 3 + +private const val YEAR_LENGTH = 4 + +/** + * What each character of one password costs an attacker, worked out once. + * + * Every pair in the vault asks the same questions about the same passwords, and each + * answer depends on one password alone. Classifying up front turns every later question + * into an array subtraction, which is what makes a dictionary lookup affordable inside a + * sweep that is quadratic in the size of the vault. + * + * Characters fall into four kinds: + * + * - [WORD]: a run of letters [WordModel] reads as a word, in any spelling, or a run long + * enough that its shape alone says so. Free. + * - [FREE]: a four digit year, or a run of three digits or fewer. A year falls in a few + * dozen tries and a short counter is habit, so neither is evidence. + * - [SEPARATOR]: what holds a passphrase together. Free inside a run of words, but not + * on its own. + * - [SECRET]: everything else, which is what an attacker actually has to crack. + * + * The whole password is classified, never a fragment of it, so a word cut in half by a + * shared prefix is still recognised as the word it is part of. + */ +internal class PasswordShape(private val raw: CharArray) { + + val size = raw.size + + val canonical: CharArray = raw.canonicalize() + + private val kind = ByteArray(size) + + /** Where the run holding each character began, so "one slot moved" is answerable. */ + private val slotStart = IntArray(size) + + /** Word runs in order, as parallel start/end arrays. Disjoint by construction. */ + private val wordStarts: IntArray + private val wordEnds: IntArray + + /** Running counts, so every range question below is two array reads. */ + private val secretBefore = IntArray(size + 1) + private val crackableBefore = IntArray(size + 1) + private val unwordlikeBefore = IntArray(size + 1) + + init { + val starts = ArrayList() + val ends = ArrayList() + classify(starts, ends) + wordStarts = starts.toIntArray() + wordEnds = ends.toIntArray() + + for (index in 0 until size) { + secretBefore[index + 1] = secretBefore[index] + if (isSecret(index)) 1 else 0 + crackableBefore[index + 1] = + crackableBefore[index] + if (kind[index] == SECRET) 1 else 0 + unwordlikeBefore[index + 1] = + unwordlikeBefore[index] + if (isWordlike(index)) 0 else 1 + } + } + + /** + * Length of `[from, until)` once the parts an attacker gets for free are discounted. + * + * A separator counts, because on its own it is a character like any other. A run of + * them between words does not, which is what [readsAsWords] is for. + */ + fun secretLength(from: Int, until: Int): Int = + if (from >= until) 0 else countBetween(secretBefore, from, until) + + /** + * Whether `[from, until)` is nothing but words and the separators between them. Such + * an end counts for nothing at all, however long it is: two passwords that share only + * "correct-horse-" share only what a wordlist hands out. + */ + fun readsAsWords(from: Int, until: Int): Boolean = + from < until && countBetween(unwordlikeBefore, from, until) == 0 + + /** + * Whether `[from, until)` holds nothing an attacker would have to crack. + * + * Unlike [secretLength] a separator does not count here. The question this answers is + * what the difference between two passwords is made of, and a passphrase written with + * dots instead of hyphens has not changed its secret. + */ + fun holdsNothingToCrack(from: Int, until: Int): Boolean = + from >= minOf(until, size) || countBetween(crackableBefore, from, until) == 0 + + /** Whole words inside `[from, until)`. A word cut by the range does not count. */ + fun wordsWithin(from: Int, until: Int): Int { + var count = 0 + for (index in wordStarts.indices) + if (wordStarts[index] >= from && wordEnds[index] <= until) count++ + return count + } + + /** + * The number `[from, until)` spells, or -1 when it is not digits the whole way or is + * longer than a Long holds. Leading zeros read as the number they spell, so "06" sits + * five away from "11". + */ + fun numberWithin(from: Int, until: Int): Long { + val start = from.coerceAtLeast(0) + val end = minOf(until, size) + if (start >= end || end - start > MAX_DIGITS) return -1 + + var value = 0L + for (index in start until end) { + if (!raw[index].isDigit()) return -1 + value = value * 10 + (raw[index] - '0') + } + return value + } + + /** + * The whole number the digits in `[from, until)` belong to, or -1 when the range holds + * no digits or holds parts of two different numbers. + * + * Reaching outside the range is the point. What separates two passwords is rarely a + * whole number: "Summer2023!" and "Summer2024!" disagree about one character of one, + * and the question worth asking is what 2023 and 2024 are to each other, not what 3 + * and 4 are. + */ + fun numberAround(from: Int, until: Int): Long { + val start = from.coerceAtLeast(0) + val end = minOf(until, size) + var at = start + while (at < end && !raw[at].isDigit()) at++ + if (at >= end) return -1 + + var runStart = at + while (runStart > 0 && raw[runStart - 1].isDigit()) runStart-- + var runEnd = at + while (runEnd < size && raw[runEnd].isDigit()) runEnd++ + + // A range straddling two numbers says nothing about either of them. + for (index in runEnd until end) if (raw[index].isDigit()) return -1 + + return numberWithin(runStart, runEnd) + } + + /** + * Whether a run begins at [index], so a boundary drawn here does not cut through the + * middle of a word or a number. The ends of the password count as boundaries. + */ + fun startsRun(index: Int): Boolean = + index <= 0 || index >= size || slotStart[index] == index + + /** + * Whether `[from, until)` stays inside one run, and that run is not a word. + * + * Bumping a year, a counter or a symbol moves a single slot and leaves everything else + * where it was. Two slots moving at once is two independent choices, which is what + * unrelated passwords off the same template look like: "Vanilla2000+" and + * "Vanilla2007#" agree on the word and on nothing else. + * + * A word never counts, however alone it stands. Trading one word for another is + * trading the whole of what an attacker would have had to guess, so "garden22" and + * "harden22" are not one password with an edit in it. + */ + fun movesOneSlot(from: Int, until: Int): Boolean { + val end = minOf(until, size) + if (from >= end) return true + if (kind[from] == WORD) return false + + val slot = slotStart[from] + for (index in from until end) if (slotStart[index] != slot) return false + return true + } + + fun wipe() = canonical.fill('') + + private fun countBetween(running: IntArray, from: Int, until: Int) = + running[until.coerceAtMost(size)] - running[from.coerceAtLeast(0)] + + private fun isSecret(index: Int) = kind[index] == SECRET || kind[index] == SEPARATOR + + private fun isWordlike(index: Int) = kind[index] == WORD || kind[index] == SEPARATOR + + private fun classify(starts: ArrayList, ends: ArrayList) { + var index = 0 + while (index < size) { + if (index + YEAR_LENGTH <= size && isYearAt(index)) { + mark(FREE, index, index + YEAR_LENGTH) + index += YEAR_LENGTH + continue + } + + val word = wordAt(index) + if (word > 0) { + mark(WORD, index, index + word) + starts += index + ends += index + word + index += word + continue + } + + val digits = digitRunAt(index) + if (digits > 0) { + // Past the whole run either way, so a long one cannot be discounted a few + // digits at a time from its tail. + val free = digits <= SHORT_DIGIT_RUN || countsOrRepeats(index, index + digits) + mark(if (free) FREE else SECRET, index, index + digits) + index += digits + continue + } + + // A symbol stands alone: two of them side by side are two choices, not one. + mark(if (raw[index] in WORD_SEPARATORS) SEPARATOR else SECRET, index, index + 1) + index++ + } + } + + private fun mark(value: Byte, from: Int, until: Int) { + kind.fill(value, from, until) + for (index in from until until) slotStart[index] = from + } + + /** + * Length of the word starting at [index]: shape first, the model only for what shape + * cannot read. + * + * Shape comes first because it knows a capital starts something. The model scores + * whatever run of letters it is handed, and in "SummerTime" that run is ten letters + * and one word where a person wrote two. Losing the boundary loses the count, and two + * shared words is evidence a pair is one password edited where one shared word is not. + * + * The model's job is the spellings shape was never able to read: a word that is + * shouted, leeted, or capitalised in the middle. Shape keeps the rest, because plenty + * of what a person writes into a password is in no dictionary and is still no secret, + * like a site name or a surname, and those are written the way words are. + */ + private fun wordAt(index: Int): Int { + val start = if (raw[index].isUpperCase()) index + 1 else index + var end = start + while (end < size && raw[end].isLowerCase()) end++ + val run = if (end == start) 0 else end - index + if (run >= WORD_RUN) return run + + return WordModel.wordRunAt(canonical, raw, index, size) + } + + /** + * Whether `[from, until)` counts up, counts down, or repeats one digit. + * + * "12345678" is as long as a number gets and costs an attacker nothing, and so does + * "87654321" and "000000". Treating a run that long as a secret purely because of its + * length reported two weak passwords as a pair whenever they happened to start + * counting from the same place, which was a seventh of every false pair left. + */ + private fun countsOrRepeats(from: Int, until: Int): Boolean { + val step = raw[from + 1] - raw[from] + if (step != 0 && step != 1 && step != -1) return false + + for (index in from + 1 until until) if (raw[index] - raw[index - 1] != step) return false + return true + } + + private fun digitRunAt(index: Int): Int { + var end = index + while (end < size && raw[end].isDigit()) end++ + return end - index + } + + private fun isYearAt(index: Int): Boolean { + val leads = raw[index] == '1' && raw[index + 1] == '9' || + raw[index] == '2' && raw[index + 1] == '0' + return leads && raw[index + 2].isDigit() && raw[index + 3].isDigit() + } + + private companion object { + const val SECRET: Byte = 0 + const val FREE: Byte = 1 + const val WORD: Byte = 2 + const val SEPARATOR: Byte = 3 + + /** Digits a Long still holds. Past this the value is not worth reading. */ + const val MAX_DIGITS = 18 + } +} + +internal fun commonPrefixLength(first: CharArray, second: CharArray): Int { + val limit = minOf(first.size, second.size) + var length = 0 + while (length < limit && first[length] == second[length]) length++ + return length +} + +internal fun commonSuffixLength(first: CharArray, second: CharArray): Int { + val limit = minOf(first.size, second.size) + var length = 0 + while (length < limit && first[first.size - 1 - length] == second[second.size - 1 - length]) + length++ + return length +} + +/** + * Optimal string alignment distance, which counts insertions, deletions, substitutions + * and transpositions of adjacent characters. + * + * Gives up as soon as a whole row exceeds [limit] and returns `limit + 1`. Most pairs in + * a vault are nowhere near each other, so that early exit is what keeps the pairwise + * sweep affordable. + * + * [rows] is three scratch rows, each at least `second.size + 1` long. They are handed in + * rather than allocated because the sweep runs this once per pair, and three arrays per + * pair is the bulk of what the check churns through. + */ +internal fun boundedDistance( + first: CharArray, + second: CharArray, + limit: Int, + rows: DistanceRows, +): Int { + if (abs(first.size - second.size) > limit) return limit + 1 + + var twoRowsAgo = rows.first + var previousRow = rows.second + var currentRow = rows.third + for (j in 0..second.size) previousRow[j] = j + + for (i in 1..first.size) { + currentRow[0] = i + var bestInRow = i + + for (j in 1..second.size) { + val substitution = if (first[i - 1] == second[j - 1]) 0 else 1 + var cost = minOf( + previousRow[j] + 1, + currentRow[j - 1] + 1, + previousRow[j - 1] + substitution, + ) + if (i > 1 && j > 1 && first[i - 1] == second[j - 2] && first[i - 2] == second[j - 1]) + cost = minOf(cost, twoRowsAgo[j - 2] + 1) + + currentRow[j] = cost + if (cost < bestInRow) bestInRow = cost + } + + if (bestInRow > limit) return limit + 1 + + val recycled = twoRowsAgo + twoRowsAgo = previousRow + previousRow = currentRow + currentRow = recycled + } + + return previousRow[second.size] +} + +/** Scratch space for [boundedDistance], reused across every pair in one sweep. */ +internal class DistanceRows(width: Int) { + val first = IntArray(width + 1) + val second = IntArray(width + 1) + val third = IntArray(width + 1) +} + +/** Value-equality wrapper so a char array can key a map. */ +internal class CharArrayKey(private val value: CharArray) { + override fun equals(other: Any?) = other is CharArrayKey && value.contentEquals(other.value) + override fun hashCode() = value.contentHashCode() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt new file mode 100644 index 000000000..a51d5e64a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordChecker.kt @@ -0,0 +1,310 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.checker.SimilarPasswordChecker.Companion.AFFIX_SHARE +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.RelationType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.math.abs + +/** + * Flags passwords that are variations of one another rather than outright copies: + * leetspeak rewrites ("P@ssw0rd!" against "password!") and templates where only one part + * moved ("Summer2023!" against "Summer2024!", "facebook_Kd8!" against "twitter_Kd8!"). + * + * Exact copies are left to [ReusePasswordCheck] on purpose. Reporting them here too would + * hang a second edge on every reused pair and count the same logins again in the summary + * line. Copies still appear inside a wider family, which is not the same thing: two + * copies of a password sitting beside a rewrite of it are each a variation of that + * rewrite, so all of them belong in the one relation. + */ +@Single +internal class SimilarPasswordChecker : PasswordHealthChecker { + + override val type = CheckKind.Similarity + + override suspend fun check(candidates: List): CheckOutcome = + withContext(Dispatchers.Default) { + // A shape holds a canonical form of the password, lowercased and leet-folded, + // which still leaves it readable. It gets the same wipe as the original. + val shapes = candidates.map { it to PasswordShape(it.password) } + + val relations = try { + val comparable = shapes.filter { (_, shape) -> shape.size >= MIN_LENGTH } + + val edges = mutableSetOf>() + collectLeetFamilies(comparable, into = edges) + collectVariantPairs(comparable, into = edges) + + edges.map { + HealthFinding.Relation(relatedItemIds = it, type = RelationType.Similar) + } + } finally { + shapes.forEach { (_, shape) -> shape.wipe() } + } + + CheckOutcome(findings = relations) + } + + private fun collectLeetFamilies( + comparable: List>, + into: MutableSet>, + ) { + comparable.groupBy { (_, shape) -> CharArrayKey(shape.canonical) } + .values + .filter { family -> family.distinctBy { CharArrayKey(it.first.password) }.size > 1 } + .mapTo(into) { family -> family.mapTo(mutableSetOf()) { (candidate, _) -> candidate.id } } + } + + private suspend fun collectVariantPairs( + comparable: List>, + into: MutableSet>, + ) { + val rows = DistanceRows(comparable.maxOfOrNull { (_, shape) -> shape.size } ?: return) + + for (i in comparable.indices) { + currentCoroutineContext().ensureActive() + val (first, firstShape) = comparable[i] + + for (j in i + 1..comparable.lastIndex) { + val (second, secondShape) = comparable[j] + + if (first.password.contentEquals(second.password)) continue + if (firstShape.canonical.contentEquals(secondShape.canonical)) continue + if (isVariant(firstShape, secondShape, rows)) into += setOf(first.id, second.id) + } + } + } + + private fun isVariant( + first: PasswordShape, + second: PasswordShape, + rows: DistanceRows, + ): Boolean { + val prefix = commonPrefixLength(first.canonical, second.canonical) + val suffix = commonSuffixLength(first.canonical, second.canonical) + val shared = weighPrefix(first, second, prefix) + weighSuffix(first, second, suffix) + + if (extendsTheOther(first, second, prefix, suffix)) return true + if (sharesTemplate(first, second, shared)) return true + if (!carriesEvidence(first, second, prefix, suffix, shared)) return false + + val allowed = (minOf(first.size, second.size) / MAX_DISTANCE_RATIO) + .coerceIn(1, MAX_DISTANCE) + return boundedDistance(first.canonical, second.canonical, allowed, rows) <= allowed + } + + /** + * Whether one password is the whole of the other with something added at one end. + * + * The edit budget cannot answer this: it is proportional to length and an appended + * site token is not, so past three or four characters the budget rules out a pair one + * of which plainly contains the other. + * + * Containment is evidence even when the shared part is guessable from end to end. + * Nothing in "Summer2023" has to be cracked, but an attacker holding + * "Summer2023!ForWork" tries it anyway, and that is the question being asked. + * + * What it must not do is read a stem as a password. The added part has to begin where + * a run begins, or "carpenter" pulls in "carpenters" and every passphrase built on a + * shared stem drags its neighbours in with it. + */ + private fun extendsTheOther( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + ): Boolean { + val shorter = minOf(first.size, second.size) + if (shorter < MIN_CONTAINED_LENGTH) return false + if (first.size == second.size) return false + + val longer = if (first.size > second.size) first else second + if (prefix >= shorter) return longer.startsRun(shorter) + if (suffix >= shorter) return longer.startsRun(longer.size - shorter) + return false + } + + /** + * Whether the two were built from the same template with one part moved. + * + * Only shared material an attacker would have to crack counts. A year, a dictionary + * word and a site name are guessed rather than cracked, so [PasswordShape.secretLength] + * takes them out of the shared ends, and an end that reads as words the whole way + * along counts for nothing at all. What is left has to cover [AFFIX_SHARE] of the + * secret material in the longer password, so a short password cannot pull a long one + * in behind a prefix that merely happens to be most of it. + */ + private fun sharesTemplate(first: PasswordShape, second: PasswordShape, shared: Int): Boolean { + if (minOf(first.size, second.size) < MIN_TEMPLATE_LENGTH) return false + if (shared < MIN_AFFIX_LENGTH) return false + + val longer = if (first.size >= second.size) first else second + return shared >= AFFIX_SHARE * longer.secretLength(0, longer.size) + } + + /** + * Whether a pair the edit budget would accept is worth reporting at all. + * + * Being a few edits apart is not the same as being the same password. Two short + * passwords built the same way land near each other by construction, which is why + * almost every false pair used to come from the edit rule. + * + * Sharing material an attacker has to crack is evidence on its own ("b7Kq-vn2LpXw" + * with one letter of difference). Failing that, the shared ends have to hold at least + * one whole word, and then the difference has to be the kind a person makes to their + * own password rather than the kind that tells two passwords apart: + * + * - nothing to crack changed and no whole word went with it, so the edit landed + * inside a word, a year or the punctuation ("harbor_meadow_84" against + * "harbr_meadow_84"), or + * - exactly one slot moved ("Summer2023!" against "Summer2024!"). + * + * Sharing two whole words is evidence on its own. Letting it buy only a smaller edit + * budget instead bought under a point of precision and cost more than two of recall; + * what actually separates "P3n9uinLaurel2024@" from "P3n9uinLaurel2003+" is + * [numbersMovedByAStep]. + * + * What this turns away is the pair with none of that: "garden22" beside "harden22", + * where the edit broke the only word there was; "facebook_Kd8!" beside + * "facebook_Xq2!", where the shared part is a site name and each side keeps a token + * the other says nothing about; and "Vanilla2000+" beside "Vanilla2007#", where two + * slots moved at once, which is two choices rather than one edit. + */ + private fun carriesEvidence( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + shared: Int, + ): Boolean { + val firstEnd = maxOf(prefix, first.size - suffix) + val secondEnd = maxOf(prefix, second.size - suffix) + if (!numbersMovedByAStep(first, second, prefix, firstEnd, secondEnd)) return false + + if (shared >= MIN_AFFIX_LENGTH) return true + + val words = minOf(sharedWords(first, prefix, suffix), sharedWords(second, prefix, suffix)) + if (words >= SHARED_WORDS_AS_EVIDENCE) return true + if (words < MIN_SHARED_WORDS) return false + + return first.holdsNothingToCrack(prefix, firstEnd) && + second.holdsNothingToCrack(prefix, secondEnd) || + first.movesOneSlot(prefix, firstEnd) && second.movesOneSlot(prefix, secondEnd) + } + + /** + * Whether the numbers the two disagree about moved by a step a person makes. + * + * A counter someone bumped goes up by one, and a year rolls on by one or two. Two + * numbers that merely occupy the same slot land wherever they land, so eighty-two + * apart is not a bump, it is two people picking a number. Nothing else separates + * "Phoenix*10" beside "Phoenix*92" from "Summer2023!" beside "Summer2024!". + * + * The numbers compared are the whole ones the difference falls inside, not the part of + * them that differs, so a year and the symbol after it moving together is still read + * as 2024 against 2003. Anything that is not one number on both sides is left to the + * rules that follow. + */ + private fun numbersMovedByAStep( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + firstEnd: Int, + secondEnd: Int, + ): Boolean { + val before = first.numberAround(prefix, firstEnd) + val after = second.numberAround(prefix, secondEnd) + if (before < 0 || after < 0) return true + + return abs(before - after) <= MAX_COUNTER_STEP + } + + /** Whole words inside the shared ends. The two ends can overlap on a short password. */ + private fun sharedWords(shape: PasswordShape, prefix: Int, suffix: Int): Int { + val tail = maxOf(prefix, shape.size - suffix) + return shape.wordsWithin(0, prefix) + shape.wordsWithin(tail, shape.size) + } + + private fun weighPrefix(first: PasswordShape, second: PasswordShape, prefix: Int): Int { + if (first.readsAsWords(0, prefix) && second.readsAsWords(0, prefix)) return 0 + + return minOf(first.secretLength(0, prefix), second.secretLength(0, prefix)) + } + + private fun weighSuffix(first: PasswordShape, second: PasswordShape, suffix: Int): Int { + if (first.readsAsWords(first.size - suffix, first.size) && + second.readsAsWords(second.size - suffix, second.size) + ) return 0 + + return minOf( + first.secretLength(first.size - suffix, first.size), + second.secretLength(second.size - suffix, second.size), + ) + } + + companion object { + /** + * Below this, similarity stops meaning anything: short passwords collide by + * accident, and the strength check already reports them. + */ + private const val MIN_LENGTH = 6 + + /** The shared-affix rule needs real length behind it to stay quiet. */ + private const val MIN_TEMPLATE_LENGTH = 8 + private const val MIN_AFFIX_LENGTH = 4 + + /** + * How much of a password has to sit inside another before containment means + * anything. Short strings turn up inside longer ones by accident. + */ + private const val MIN_CONTAINED_LENGTH = 8 + + /** + * Whole words the shared ends have to keep before an edit-distance hit is looked at + * at all. One is what separates a typo from a word swap: an edit inside the only + * word there was leaves no whole word behind it. + */ + private const val MIN_SHARED_WORDS = 1 + + /** + * The share of the longer password's secret material the shared part has to cover. + * Tuned on a labelled corpus; see SimilaritySensitivitySweep for what moving it + * costs in recall. + */ + private const val AFFIX_SHARE = 0.4 + + /** + * Shared words that are evidence on their own, whatever the difference is made of. + * Two is the point where a wordlist stops explaining the overlap: unrelated + * passphrases share one word constantly and three almost never. + */ + private const val SHARED_WORDS_AS_EVIDENCE = 2 + + /** + * How far a number may travel and still read as one someone bumped rather than two + * numbers that happen to sit in the same slot. People increment; they do not jump. + * Wide enough that a year skipping a couple of renewals survives. + */ + private const val MAX_COUNTER_STEP = 3 + + /** One edit per this many characters still counts as the same password. */ + private const val MAX_DISTANCE_RATIO = 4 + + /** + * However long the two are, never allow more edits than this. A real variation + * moves one slot: a typo, a counter, a symbol, a year. The proportional budget on + * its own gives a twenty-eight character passphrase seven edits, which is a whole + * word, and two unrelated passphrases off the same wordlist then start looking like + * one another. + */ + private const val MAX_DISTANCE = 4 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt new file mode 100644 index 000000000..4397b3855 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModel.kt @@ -0,0 +1,262 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.checker.WordModel.WORTH_IT +import me.gosimple.nbvcxz.resources.DictionaryUtil +import kotlin.math.ln + +/** + * Whether a run of letters reads like a word, which is the same question as whether an + * attacker gets it for free. + * + * A word list answers this in one language only. A German, French or Spanish word + * shouted or leeted past a shape rule is not on an English list, so the people whose + * passwords are not written in English are exactly the people the list does not help, + * and carrying a list per language does not scale. + * + * So instead: words are not random letters. "ratte" and "rttae" use the same five + * letters and only one could be a word, because languages that share an alphabet share a + * great deal of how letters follow one another. An order 3 Markov model counts, for + * every pair of letters, which letter came next, and reads a run by how much less of a + * surprise it is than the same many letters drawn at random. Trained on English alone it + * still reads "kartoffel", "papillon" and "mariposa" as words and "b7Kqxz" as a secret, + * because what it learned is mostly not English but the shape of European spelling. + * + * Training uses the nbvcxz dictionary already on the classpath, so this costs no asset + * and no download: 19,683 bytes in place of 22,160 hashes. + * + * Scoring takes a range of an already canonical array rather than a string. Building + * strings out of password fragments would leave copies behind that cannot be wiped. + */ +internal object WordModel { + + /** + * Shorter than this and a "word" is a fragment: at two characters a random token can + * be nibbled away a syllable at a time until nothing is left to crack. Three is as low + * as it can go, because passphrase wordlists are full of three letter words and + * "Oak-Elk-Ivy-Fig-Owl" has to read as five of them. + */ + const val MIN_WORD = 3 + + /** + * Length of the word starting at [from], or 0. + * + * Where the word ends has to be worked out, not assumed, because canonicalising merges + * a word with whatever follows it: "H@rb0r1993+" folds to eleven letters in a row. + * Asking whether those eleven are a word only ever answers no. + * + * So every prefix is weighed against chance. Each character earns the model's opinion + * of it less what a letter drawn at random would have cost, and the word is the prefix + * where that running total peaks: "harbor" peaks at six and every letter of "igget" + * after it takes the total back down. An average would not do this, because six + * confident characters carry five unlikely ones and the whole of "harborigget" reads + * as fine on average. + * + * A run that never gets [WORTH_IT] ahead of chance is not a word at all. + * + * The run is taken from [canonical], so a leeted spelling is one run and not three. + * [raw] only has to show the run was not digits that happened to fold into letters. + */ + fun wordRunAt(canonical: CharArray, raw: CharArray, from: Int, until: Int): Int { + var end = from + while (end < until && canonical[end].isLetter()) end++ + + val run = end - from + if (run < MIN_WORD) return 0 + + var back2 = EDGE + var back1 = EDGE + var running = 0.0 + var readable = 0 + var sawLetter = false + var best = WORTH_IT + var word = 0 + + for (index in from until end) { + // A run of digits can fold into letters by accident: "1337" canonicalises to + // "ieet". One real letter is enough to tell a respelled word from that. + if (raw[index].isLetter()) sawLetter = true + + val symbol = symbolOf(canonical[index]) + if (symbol < 0) { + // A letter the model never saw is not evidence of anything. Pick the word + // up again on the far side of it rather than score a surprise. + back2 = EDGE + back1 = EDGE + continue + } + + running += logProbability(back2, back1, symbol) - BY_CHANCE + if (!raw[index].isLetter()) running -= RESPELLING + readable++ + back2 = back1 + back1 = symbol + + val length = index - from + 1 + if (length < MIN_WORD || readable < MIN_WORD || !sawLetter) continue + + // A word can be spelled with digits in it and does not end in one. Every + // character of "1993+" folds to a letter, so "harborigget" reads as a run of + // eleven, and the only thing that still says where "harbor" stopped is that + // the characters after it were never letters to begin with. + if (!raw[index].isLetter()) continue + + // Where a word stops is as telling as how it runs. Stopping after "harbor" is + // ordinary and stopping after "harborig" is not, so the closing edge is + // charged for like any other character. + val whole = running + logProbability(back2, back1, EDGE) - BY_CHANCE + if (whole > best) { + best = whole + word = length + } else if (best - whole > GIVE_UP) { + // The word is over and the rest of the run belongs to something else. + // Stopping matters because a later stretch can read well on its own + // account and drag the word across everything in between: "qwertyuiop" is + // two readable halves and an unreadable join. The caller asks again here. + break + } + } + + if (!sawLetter) return 0 + + // Letters in an alphabet the model was never shown. Letters in a row inside a + // password are a word far more often than they are a secret, and refusing to read + // them would put every language outside this one back where the English list left + // them. + if (readable < MIN_WORD) return run + + // What is left over is too short to be a word of its own, so it is the tail of + // this one. "CEDAR" reads best as "ced", since a great many words end in "ed", and + // leaving "ar" behind charges an attacker for two characters nobody chose + // separately. Only letters are taken this way: the leftover of "Garden@" is a + // respelling and belongs to whatever comes after it. + if (word > 0 && run - word < MIN_WORD && allLetters(raw, from + word, from + run)) + return run + + return word + } + + private fun allLetters(raw: CharArray, from: Int, until: Int): Boolean { + for (index in from until until) if (!raw[index].isLetter()) return false + return true + } + + private fun symbolOf(char: Char) = if (char in 'a'..'z') char - 'a' else -1 + + private fun logProbability(back2: Int, back1: Int, next: Int): Double = + -(table[(back2 * ALPHABET + back1) * ALPHABET + next].toInt() and 0xFF) / SCALE + + private val table: ByteArray by lazy { train() } + + /** + * Counts every letter that followed every pair of letters, then stores the result as + * one byte of surprise per cell. A byte is 1/16th of a nat apart from its neighbour + * and the totals are compared at a distance of whole nats, so the rounding never + * decides anything. + */ + private fun train(): ByteArray { + val counts = IntArray(CELLS) + val totals = IntArray(CONTEXTS) + + // english is ordinary vocabulary, eff_large the list passphrase generators draw + // from. Names, surnames and the leaked password list are deliberately left out: a + // model taught that "qwerty" is ordinary would stop charging for it. + listOf(DictionaryUtil.english, DictionaryUtil.eff_large).forEach { name -> + DictionaryUtil.loadUnrankedDictionary(name).keys.forEach { word -> + val trimmed = word.trim().lowercase() + if (trimmed.length >= MIN_WORD && trimmed.all { it in 'a'..'z' }) + // Trained canonicalised, because that is the form a lookup arrives in. + // Skip this and no word holding an l reads as one: "maple" + // canonicalises to "mapie", since l, 1, | and ! all read as i. + learn(trimmed.toCharArray().canonicalize(), counts, totals) + } + } + + val table = ByteArray(CELLS) + for (context in 0 until CONTEXTS) { + val total = totals[context] + ALPHABET * SMOOTHING + for (next in 0 until ALPHABET) { + val cell = context * ALPHABET + next + val probability = (counts[cell] + SMOOTHING) / total + table[cell] = (-ln(probability) * SCALE).toInt().coerceIn(0, 255).toByte() + } + } + return table + } + + private fun learn(word: CharArray, counts: IntArray, totals: IntArray) { + var back2 = EDGE + var back1 = EDGE + word.forEach { char -> + val symbol = symbolOf(char) + val context = back2 * ALPHABET + back1 + counts[context * ALPHABET + symbol]++ + totals[context]++ + back2 = back1 + back1 = symbol + } + val context = back2 * ALPHABET + back1 + counts[context * ALPHABET + EDGE]++ + totals[context]++ + } + + /** 26 letters and the symbol standing for the edge of a word. */ + private const val ALPHABET = 27 + private const val EDGE = 26 + private const val CONTEXTS = ALPHABET * ALPHABET + private const val CELLS = CONTEXTS * ALPHABET + + /** + * What every letter is credited with having followed every pair, so that nothing is + * impossible and a word built of unseen pairs is merely very expensive. + * + * Deliberately tiny. A letter the training set never put after a pair it has seen + * thousands of times then costs around eleven nats, and that is what separates a token + * from a word: a token is mostly ordinary letters in an order no word takes. Making it + * larger flattens exactly the distinction the model exists to draw. + */ + private const val SMOOTHING = 0.01 + + private const val SCALE = 16.0 + + /** + * What one character of a word would have cost had it been drawn at random, which is + * what every character of a candidate word is measured against. + * + * Seventy, not twenty six, because what a letter of a password competes against is not + * another letter: it is a digit or a symbol that canonicalising turned into one. An + * ordinary character of a word beats this by around two nats. + */ + private val BY_CHANCE = ln(1.0 / 70) + + /** + * What a character costs a word for having been written as something other than a + * letter. + * + * People do write "Dr@gon", and six ordinary characters earn enough to carry one + * respelling, so it still reads as a word. What does not survive is a word reaching + * across a respelling into another one: "Uzb+audible+Uzb" folds to a single run of + * letters and only the crossings say it is three things. + */ + private const val RESPELLING = 6.0 + + /** + * How far the reading may fall below its best before the word is taken to have ended. + * + * Wide, because the reading at a given length asks whether a word ends there, and + * inside a word the honest answer is no: nothing ends in "ceda", so a narrow margin + * would stop before reaching "cedar". What it still catches is a run that has gone well + * past anything a word does. + */ + private const val GIVE_UP = 6.0 + + /** + * How far ahead of chance a run has to read before it is worth calling a word. + * + * A model says yes to anything spelled the way words are spelled, and at three + * characters a great many tokens are: "sparkasse-M4H62C" would give up "mah" and + * "discord EwBiH" would give up "ewbi", and what is left is no longer enough to tell + * two tokens apart. Asking for a real margin rather than a longer run is what keeps + * "oak" and "elk" while turning those down. + */ + private const val WORTH_IT = 4.0 +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt new file mode 100644 index 000000000..f75807f6c --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import java.util.UUID + +/** Candidates whose ids carry their position, so a finding can be read back as text. */ +internal fun candidates(passwords: List): List = + passwords.mapIndexed { index, password -> + PasswordCandidate( + id = UUID(0L, index.toLong()), + title = "item $index", + username = null, + score = PasswordScore.Moderate, + password = password.toCharArray(), + urls = emptyList(), + ) + } + +internal fun candidates(vararg passwords: String) = candidates(passwords.toList()) + +/** The flagged groups, as the passwords themselves, so a failure reads plainly. */ +internal suspend fun flag(passwords: List): Set> { + val candidates = candidates(passwords) + val byId = candidates.associateBy({ it.id }, { passwords[it.id.leastSignificantBits.toInt()] }) + return SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + .mapTo(mutableSetOf()) { finding -> + finding.relatedItemIds.mapTo(mutableSetOf()) { byId.getValue(it) } + } +} + +internal suspend fun flag(vararg passwords: String) = flag(passwords.toList()) diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt new file mode 100644 index 000000000..81240b81f --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/PassphraseNoiseTest.kt @@ -0,0 +1,115 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import kotlinx.coroutines.test.runTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +class PassphraseNoiseTest { + + private val words = SimilarityCorpus.passphraseWords + + @Test + fun oneStyleThroughoutAVaultRaisesNothing() = runTest(timeout = 5.minutes) { + Style.entries.forEach { style -> + val vault = List(250) { style.build(words, Random(it.toLong() + style.ordinal * 1000)) } + .distinct() + + val flagged = flag(vault) + + assertEquals( + emptySet(), + flagged, + "style ${style.name} raised ${flagged.size} groups among independent passphrases", + ) + } + } + + @Test + fun aNarrowPersonalVocabularyStaysNearlyQuiet() = runTest(timeout = 5.minutes) { + val narrow = words.take(40) + val vault = List(250) { Style.HyphenLower.build(narrow, Random(it.toLong())) }.distinct() + + val flagged = flag(vault) + + assertTrue( + flagged.size <= vault.size / 25, + "${flagged.size} groups among ${vault.size} passphrases from a 40 word vocabulary: $flagged", + ) + } + + @Test + fun sharingOneWordIsNotEnough() = runTest { + assertEquals( + emptySet(), + flag("correct-horse-battery-staple", "correct-otter-lantern-puzzle"), + ) + } + + @Test + fun sharingTwoLeadingWordsIsNotEnough() = runTest { + assertEquals( + emptySet(), + flag("correct-horse-battery-staple", "correct-horse-lantern-puzzle"), + ) + } + + @Test + fun sharingTwoLeadingWordsIsNotEnoughInCapitals() = runTest { + assertEquals( + emptySet(), + flag("MAPLE-CEDAR-WILLOW-ASPEN", "MAPLE-CEDAR-SPRUCE-JUNIPER"), + ) + } + + @Test + fun sharingTwoLeadingShortWordsIsNotEnough() = runTest { + assertEquals(emptySet(), flag("Oak-Elk-Ivy-Fig-Owl", "Oak-Elk-Ant-Bee-Cow")) + } + + @Test + fun sharingEveryWordButOneIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct-horse-battery-puzzle") + + assertEquals(1, flagged.size) + } + + @Test + fun theSamePassphraseWithADigitAppendedIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct-horse-battery-staple7") + + assertEquals(1, flagged.size) + } + + @Test + fun theSamePassphraseRepunctuatedIsStillReported() = runTest { + val flagged = flag("correct-horse-battery-staple", "correct.horse.battery.staple") + + assertEquals(1, flagged.size) + } + + private enum class Style( + private val count: IntRange, + private val separator: Char, + private val capitalise: Boolean, + private val trailingDigits: Int, + ) { + HyphenLower(3..3, '-', false, 0), + HyphenLowerLong(6..6, '-', false, 0), + DotCapitalised(4..4, '.', true, 0), + UnderscoreWithDigits(4..4, '_', false, 2), + SpacedLower(5..5, ' ', false, 0), + ; + + fun build(words: List, random: Random): String { + val chosen = List(random.nextInt(count.first, count.last + 1)) { + val word = words.random(random) + if (capitalise) word.replaceFirstChar { char -> char.uppercase() } else word + } + val digits = (1..trailingDigits).joinToString("") { random.nextInt(10).toString() } + return chosen.joinToString(separator.toString()) + digits + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt new file mode 100644 index 000000000..784ac2e4e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerBenchmark.kt @@ -0,0 +1,478 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import java.io.File +import java.lang.management.ManagementFactory +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.system.measureNanoTime +import kotlin.test.Test +import kotlin.time.Duration.Companion.hours + +/** + * Measures [SimilarPasswordChecker] against labelled corpora: how many variations it + * finds, how many unrelated passwords it joins, and what the pairwise sweep costs as a + * vault grows. + * + * Not a unit test. Everything is read from the environment so the default run stays cheap: + * + * SIMILARITY_VAULTS=4000 SIMILARITY_POOL_SIZES=5000 \ + * SIMILARITY_SCALING_SIZES=1000,2000,4000,8000,16000,32000 \ + * ./gradlew :feature:password-health:testDebugUnitTest \ + * --tests '*SimilarPasswordCheckerBenchmark*' + * + * The report lands in `build/reports/similarity/report.txt`. + */ +class SimilarPasswordCheckerBenchmark { + + private val report = StringBuilder() + + @Test + fun measure() = runTest(timeout = 6.hours) { + val vaultCount = env("SIMILARITY_VAULTS")?.toIntOrNull() ?: 300 + val poolSizes = sizes("SIMILARITY_POOL_SIZES", default = listOf(2_000)) + val scalingSizes = sizes("SIMILARITY_SCALING_SIZES", default = listOf(500, 1_000, 2_000)) + + line("SimilarPasswordChecker measurement") + line( + "java=${System.getProperty("java.version")} cores=${cores()} " + + "maxHeap=${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB" + ) + line("") + + // Warm the JIT so the first timed run is not measuring the interpreter. + SimilarPasswordChecker().check(SimilarityCorpus.singleVault(400, seed = 1L).candidates()) + + vaultSimulation(vaultCount) + flush() + poolSizes.forEach { pool(it); flush() } + + line("== scaling: one vault of n entries ==") + line( + "%10s %14s %14s %12s %12s %12s %10s".format( + "n", "pairs", "elapsed_ms", "pairs/ms", "findings", "alloc_MB", "gc_ms", + ), + ) + scalingSizes.forEach { scale(it); flush() } + + println(report) + println("report written to ${reportFile().absolutePath}") + } + + private fun reportFile() = + File(env("SIMILARITY_REPORT_DIR") ?: "build/reports/similarity", "report.txt") + + /** Written after every section so a run that dies late still leaves its results. */ + private fun flush() { + val out = reportFile() + out.parentFile?.mkdirs() + out.writeText(report.toString()) + } + + /** + * The headline measurement: many separate vaults, each with one person's small + * vocabulary and habits. Rates are aggregated over every vault, and the per-vault + * spread says what a single user actually sees. + */ + private suspend fun vaultSimulation(count: Int) { + val stats = Stats() + var elapsed = 0L + SimilarityCorpus.vaults(count).forEach { corpus -> + val candidates = corpus.candidates() + var findings: List + elapsed += measureNanoTime { + findings = SimilarPasswordChecker().check(candidates).findings + } + stats.accumulate(corpus, edgesOf(findings), findings) + } + + line("== accuracy: $count simulated vaults ==") + line("passwords ${stats.items}") + line( + "vault size min ${stats.vaultSizes.min()}, median " + + "${stats.vaultSizes.sorted()[stats.vaultSizes.size / 2]}, max ${stats.vaultSizes.max()}" + ) + line("total elapsed ${elapsed / 1_000_000} ms") + line("") + stats.write() + + val quiet = stats.falsePositivesPerVault.count { it == 0 } + val sortedFp = stats.falsePositivesPerVault.sorted() + line("-- what one user sees --") + line("vaults with no false pair $quiet / $count ${pct(ratio(quiet, count))}") + line( + "false pairs per vault median ${sortedFp[sortedFp.size / 2]}, " + + "p90 ${sortedFp[(sortedFp.size * 0.9).toInt()]}, max ${sortedFp.last()}" + ) + val sortedGroups = stats.contaminatedPerVault.sorted() + line( + "wrong groups per vault median ${sortedGroups[sortedGroups.size / 2]}, " + + "p90 ${sortedGroups[(sortedGroups.size * 0.9).toInt()]}, max ${sortedGroups.last()}" + ) + line("") + } + + /** One undifferentiated pool, for a precision figure that does not depend on vault size. */ + private suspend fun pool(size: Int) { + val corpus = SimilarityCorpus.singleVault(size) + val candidates = corpus.candidates() + val runtime = Runtime.getRuntime() + System.gc() + val before = runtime.totalMemory() - runtime.freeMemory() + var findings: List + val elapsed = + measureNanoTime { findings = SimilarPasswordChecker().check(candidates).findings } + val after = runtime.totalMemory() - runtime.freeMemory() + + val stats = Stats() + stats.accumulate(corpus, edgesOf(findings), findings) + + line("== accuracy: one pool of $size, wide vocabulary ==") + line("elapsed ${elapsed / 1_000_000} ms") + line("heap delta ${(after - before) / (1024 * 1024)} MB") + line("") + stats.write() + } + + private suspend fun scale(size: Int) { + val pairs = size.toLong() * (size - 1) / 2 + try { + val candidates = SimilarityCorpus.singleVault(size).candidates() + var findings = 0 + val allocatedBefore = allocatedBytes() + val gcBefore = gcMillis() + val elapsed = + measureNanoTime { + findings = SimilarPasswordChecker().check(candidates).findings.size + } + val allocated = allocatedBytes() - allocatedBefore + val gc = gcMillis() - gcBefore + val millis = elapsed / 1_000_000.0 + line( + "%10d %14d %14.1f %12.0f %12d %12d %10d".format( + size, pairs, millis, pairs / max(millis, 0.001), findings, + allocated / (1024 * 1024), gc, + ), + ) + } catch (error: OutOfMemoryError) { + // The findings set holds one entry per flagged pair, so it grows with the + // square of the vault too. Running out of room is a result, not a mishap. + line("%10d %14d %14s".format(size, pairs, "OOM: ${error.message}")) + } + } + + private fun edgesOf(findings: List): Set { + val edges = HashSet() + findings.filterIsInstance().forEach { finding -> + val ids = finding.relatedItemIds.toList() + for (i in ids.indices) for (j in i + 1..ids.lastIndex) + edges += PairKey.of(ids[i], ids[j]) + } + return edges + } + + // region accumulation + + private inner class Stats { + var truePositives = 0L + var falsePositives = 0L + var falseNegatives = 0L + var duplicatesFlagged = 0L + var truePairs = 0L + var duplicatePairs = 0L + var scoredPairs = 0L + var items = 0L + var comparable = 0L + var findings = 0L + var multiMemberFindings = 0L + + var groups = 0L + var contaminatedGroups = 0L + var itemsInGroups = 0L + var largestGroup = 0 + var largestGroupSecrets = 0 + + val vaultSizes = mutableListOf() + val falsePositivesPerVault = mutableListOf() + val contaminatedPerVault = mutableListOf() + + val recallByVariant = HashMap() + val recallBySteps = HashMap() + val recallByShape = HashMap() + val causes = HashMap() + val verdicts = HashMap() + val examples = HashMap>() + val missedExamples = mutableListOf() + + fun accumulate(corpus: Corpus, predicted: Set, raw: List) { + val size = corpus.entries.size + vaultSizes += size + items += size + comparable += corpus.entries.count { it.password.length >= 6 } + findings += raw.count { it is HealthFinding.Relation } + multiMemberFindings += raw.filterIsInstance() + .count { it.relatedItemIds.size > 2 } + + truePairs += corpus.variantPairs.size + duplicatePairs += corpus.duplicatePairs.size + scoredPairs += size.toLong() * (size - 1) / 2 - corpus.duplicatePairs.size + + var vaultFalsePositives = 0 + predicted.forEach { pair -> + when { + pair in corpus.variantPairs -> truePositives++ + pair in corpus.duplicatePairs -> duplicatesFlagged++ + else -> { + falsePositives++ + vaultFalsePositives++ + record(corpus, pair) + } + } + } + falsePositivesPerVault += vaultFalsePositives + + corpus.variantPairs.forEach { pair -> + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val hit = pair in predicted + if (!hit) { + falseNegatives++ + if (missedExamples.size < 25) missedExamples += + "%-28s %-28s (%s)".format( + first.password, second.password, + if (first.relation == "base") second.relation else first.relation, + ) + } + val kind = when { + first.relation == "base" -> second.relation + second.relation == "base" -> first.relation + else -> "variant-of-variant" + } + recallByVariant.tally(kind, hit) + recallBySteps.tally(abs(first.distanceFromBase - second.distanceFromBase), hit) + recallByShape.tally(first.archetype, hit) + } + + contaminatedPerVault += contamination(corpus, predicted) + } + + private fun record(corpus: Corpus, pair: PairKey) { + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val cause = cause(first, second) + val verdict = verdict(first.password, second.password) + causes[cause] = (causes[cause] ?: 0) + 1 + verdicts[verdict] = (verdicts[verdict] ?: 0) + 1 + val bucket = examples.getOrPut(verdict) { mutableListOf() } + if (bucket.size < 8) bucket += "[%s] %-28s %-28s".format( + cause, first.password, second.password, + ) + } + + private fun contamination(corpus: Corpus, predicted: Set): Int { + val parent = HashMap() + fun find(x: ItemId): ItemId { + var root = parent.getOrPut(x) { x } + while (root != parent.getValue(root)) root = parent.getValue(root) + var cursor = x + while (cursor != root) { + val next = parent.getValue(cursor) + parent[cursor] = root + cursor = next + } + return root + } + predicted.forEach { + val a = find(it.a) + val b = find(it.b) + if (a != b) parent[a] = b + } + + val grouped = parent.keys.toList().groupBy(::find) + var contaminated = 0 + grouped.forEach { (_, members) -> + val secrets = members.map { corpus.byId.getValue(it).family }.toSet() + if (secrets.size > 1) contaminated++ + if (members.size > largestGroup) { + largestGroup = members.size + largestGroupSecrets = secrets.size + } + } + groups += grouped.size + contaminatedGroups += contaminated + itemsInGroups += parent.size + return contaminated + } + + fun write() { + val negatives = scoredPairs - truePairs + val trueNegatives = negatives - falsePositives + val precision = ratio(truePositives, truePositives + falsePositives) + val recall = ratio(truePositives, truePairs) + val f1 = + if (precision + recall == 0.0) 0.0 else 2 * precision * recall / (precision + recall) + + line("candidates $items (comparable, >=6 chars: $comparable)") + line("true variant pairs $truePairs") + line("exact dup pairs $duplicatePairs (out of scope, excluded from scoring)") + line("scored pairs $scoredPairs (negatives: $negatives)") + line("relation findings $findings ($multiMemberFindings with more than 2 members)") + line("") + line("TP $truePositives") + line("FP $falsePositives") + line("FN $falseNegatives") + line("TN $trueNegatives") + // A copy riding along inside a wider leet family, not a stray reuse edge: the + // checker never reports a pair of copies on its own. Counted here because + // expanding a family into pairs makes them look like one. + line("dup pairs inside families $duplicatesFlagged") + line("") + line("precision ${pct(precision)}") + line("recall ${pct(recall)}") + line("F1 ${"%.4f".format(f1)}") + line("specificity ${pct(ratio(trueNegatives, negatives))}") + val perMillion = falsePositives * 1_000_000.0 / max(negatives, 1) + line("FP per 1M neg pairs ${"%.1f".format(perMillion)}") + line("FP per 100 passwords ${"%.2f".format(falsePositives * 100.0 / max(items, 1))}") + line("") + line("groups shown $groups ($itemsInGroups items)") + val contaminatedShare = pct(ratio(contaminatedGroups, groups)) + line("contaminated groups $contaminatedGroups $contaminatedShare") + line( + "largest group $largestGroup items covering " + + "$largestGroupSecrets unrelated secrets" + ) + line("") + + recallByVariant.writeRecall("recall by how the variation was made") + recallBySteps.writeRecall("recall by edits between the two") { "$it step(s)" } + recallByShape.writeRecall("recall by password shape") + line("-- missed examples --") + missedExamples.take(15).forEach { line(" $it") } + line("") + + line("-- does the false flag make sense? --") + verdicts.entries.sortedByDescending { it.value }.forEach { (key, count) -> + line("%-42s %8d %s".format(key, count, pct(ratio(count, falsePositives)))) + } + line("-- what the two passwords shared --") + causes.entries.sortedByDescending { it.value }.take(16).forEach { (key, count) -> + line("%-42s %8d %s".format(key, count, pct(ratio(count, falsePositives)))) + } + line("-- false positive examples --") + examples.entries.sortedBy { it.key }.forEach { (verdict, rows) -> + line(" $verdict") + rows.forEach { line(" $it") } + } + line("") + } + } + + private fun HashMap.tally(key: K, hit: Boolean) { + val bucket = getOrPut(key) { LongArray(2) } + bucket[1]++ + if (hit) bucket[0]++ + } + + /** One `hits/total` table, ordered by its key. */ + private fun > Map.writeRecall( + title: String, + name: (K) -> String = { it.toString() }, + ) { + line("-- $title --") + entries.sortedBy { it.key }.forEach { (key, bucket) -> + line( + "%-22s %7d/%-7d %s".format( + name(key), bucket[0], bucket[1], pct(ratio(bucket[0], bucket[1])), + ), + ) + } + } + + // endregion + + // region judging a false positive + + /** The surface structure that made the checker join two unrelated passwords. */ + private fun cause(first: CorpusEntry, second: CorpusEntry): String = when { + first.cluster == second.cluster && first.cluster != "none" -> first.cluster + else -> listOf(first.archetype, second.archetype).sorted().joinToString(" + ") + } + + /** + * Whether a flag on an unrelated pair is still worth showing. It is when knowing one + * password narrows the search for the other: near-identical text, or a shared base a + * rule-based cracker would try anyway. It is not when both passwords keep material the + * other tells you nothing about, and never when all they share is a site name, which is + * public. + */ + private fun verdict(first: String, second: String): String { + val canonicalFirst = first.toCharArray().canonicalize() + val canonicalSecond = second.toCharArray().canonicalize() + val shorter = min(first.length, second.length) + val rows = DistanceRows(max(canonicalFirst.size, canonicalSecond.size)) + if (boundedDistance(canonicalFirst, canonicalSecond, shorter, rows) <= 2) + return "defensible: near-identical text" + + val prefix = commonPrefixLength(canonicalFirst, canonicalSecond) + val suffix = min(commonSuffixLength(canonicalFirst, canonicalSecond), shorter - prefix) + val shared = first.take(prefix) + first.takeLast(max(suffix, 0)) + if (SimilarityCorpus.siteNames.any { shared.contains(it, ignoreCase = true) }) + return "wrong: all they share is a site name" + + val restFirst = first.drop(prefix).dropLast(max(suffix, 0)) + val restSecond = second.drop(prefix).dropLast(max(suffix, 0)) + if (isSecret(restFirst) && isSecret(restSecond)) + return "wrong: each keeps material the other does not reveal" + + return "defensible: shared guessable base" + } + + private val YEAR = Regex("(19|20)\\d{2}") + + /** Whether a fragment is unguessable: long enough, not a word, not just a year. */ + private fun isSecret(fragment: String): Boolean { + val stripped = YEAR.replace(fragment, "").filter { it.isLetterOrDigit() } + if (stripped.length < 4) return false + if (stripped.lowercase() in SimilarityCorpus.vocabulary) return false + return !stripped.all { it.isDigit() } + } + + // endregion + + private fun cores() = Runtime.getRuntime().availableProcessors() + + /** + * Bytes handed out across every live thread. boundedDistance allocates three int rows + * per comparison that survives its length prefilter, so the sweep churns far more + * memory than it holds, and that churn does not show up in a heap reading. + */ + private fun allocatedBytes(): Long { + val threads = ManagementFactory.getThreadMXBean() as? com.sun.management.ThreadMXBean + ?: return 0 + return threads.getThreadAllocatedBytes(threads.allThreadIds).filter { it > 0 }.sum() + } + + private fun gcMillis() = + ManagementFactory.getGarbageCollectorMXBeans().sumOf { it.collectionTime } + + private fun env(name: String) = System.getenv(name)?.takeIf { it.isNotBlank() } + + private fun sizes(name: String, default: List): List = + env(name)?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.takeIf { it.isNotEmpty() } + ?: default + + private fun line(text: String) { + report.append(text).append('\n') + } + + private fun ratio(numerator: Number, denominator: Number): Double { + val d = denominator.toDouble() + return if (d == 0.0) 0.0 else numerator.toDouble() / d + } + + private fun pct(value: Double) = "%.2f%%".format(value * 100) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt new file mode 100644 index 000000000..fde960bcc --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarPasswordCheckerTest.kt @@ -0,0 +1,254 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimilarPasswordCheckerTest { + + // region what the checker is for + + @Test + fun flagsALeetspeakRewrite() = runTest { + assertGrouped(setOf("password!", "P@ssw0rd!"), flag("password!", "P@ssw0rd!")) + } + + @Test + fun flagsABumpedYear() = runTest { + assertGrouped(setOf("Summer2023!", "Summer2024!"), flag("Summer2023!", "Summer2024!")) + } + + @Test + fun flagsASiteSwappedAroundASharedToken() = runTest { + assertGrouped(setOf("facebook_Kd8!", "twitter_Kd8!"), flag("facebook_Kd8!", "twitter_Kd8!")) + } + + @Test + fun flagsAWrappedTokenWithTheSiteSwapped() = runTest { + assertGrouped( + setOf("Kq7!facebook!Kq7", "Kq7!twitter!Kq7"), + flag("Kq7!facebook!Kq7", "Kq7!twitter!Kq7"), + ) + } + + @Test + fun flagsAnIncrementedCounter() = runTest { + assertGrouped( + setOf("Thistle-Garden-41", "Thistle-Garden-42"), + flag("Thistle-Garden-41", "Thistle-Garden-42") + ) + } + + // endregion + + // region a number that moved, and how far + + /** + * A counter someone bumped moves by one; eighty-two apart is two people picking a + * number. Without this every pair sharing a word and a symbol is reported, which was + * the single largest group of false pairs. + */ + @Test + fun ignoresTwoNumbersNowhereNearEachOther() = runTest { + assertEquals(emptySet(), flag("Phoenix*10", "Phoenix*92")) + } + + @Test + fun ignoresAYearThatJumpedHalfADecade() = runTest { + assertEquals(emptySet(), flag("Ginger2021-", "Ginger2015-")) + } + + /** A year rolling forward a couple of times is still one person editing one password. */ + @Test + fun flagsAYearThatMovedOnAStep() = runTest { + assertGrouped(setOf("Ginger2021-", "Ginger2023-"), flag("Ginger2021-", "Ginger2023-")) + } + + /** + * Two shared words say the base is the same. They do not say the rest is one edit: + * here the punctuation changed and the counter jumped nineteen, which is two decisions + * and therefore two passwords. + */ + @Test + fun ignoresATwoWordBaseThatWasRepunctuatedAndRenumbered() = runTest { + assertEquals(emptySet(), flag("jasper-rocket-53", "jasper.rocket.34")) + } + + /** The year and the symbol both moved, which is two decisions rather than one edit. */ + @Test + fun ignoresATwoWordBaseWhoseYearAndSymbolBothMoved() = runTest { + assertEquals(emptySet(), flag("P3n9uinLaurel2024@", "P3n9uinLaurel2003+")) + } + + /** + * Counting on the keyboard is not a secret anyone has to crack, so two weak passwords + * that agree on nothing else are two weak passwords, not a pair. + */ + @Test + fun ignoresTwoWeakPasswordsSharingOnlyACountingRun() = runTest { + assertEquals(emptySet(), flag("12345678", "1234qwer\$")) + } + + /** A number that is not going anywhere in particular is still material. */ + @Test + fun flagsASharedRunOfUnremarkableDigits() = runTest { + assertGrouped( + setOf("48213_facebook", "48213_twitter"), + flag("48213_facebook", "48213_twitter") + ) + } + + // endregion + + // region one password is the whole of another + + /** + * The commonest way a base gets reused, and the edit budget is the wrong tool for it: + * the budget is proportional to length and a site token is not. + */ + @Test + fun flagsATokenAppendedToAPassphrase() = runTest { + assertGrouped( + setOf("purple-tiger-canyon-rowan", "purple-tiger-canyon-rowanBcix9"), + flag("purple-tiger-canyon-rowan", "purple-tiger-canyon-rowanBcix9"), + ) + } + + /** + * Containment is evidence even when the shared part is guessable throughout. Nothing + * about "Summer2023" has to be cracked, but an attacker who has the longer password + * types the shorter one in next. + */ + @Test + fun flagsASiteTokenAppendedToAGuessableBase() = runTest { + assertGrouped( + setOf("Summer2023", "Summer2023!ForWork"), + flag("Summer2023", "Summer2023!ForWork"), + ) + } + + @Test + fun flagsABasePrependedTo() = runTest { + assertGrouped( + setOf("Kq7!Vn2Lp", "work-Kq7!Vn2Lp"), + flag("Kq7!Vn2Lp", "work-Kq7!Vn2Lp"), + ) + } + + /** + * A word that happens to begin a longer word is not a password inside a password, or + * every passphrase built on a stem drags its neighbours along. + */ + @Test + fun ignoresAWordThatMerelyBeginsALongerWord() = runTest { + assertEquals(emptySet(), flag("carpenter", "carpenters")) + } + + // endregion + + // region what belongs to another check, or to nothing + + @Test + fun leavesExactCopiesToTheReuseCheck() = runTest { + assertEquals(emptySet(), flag("Hunter2024!", "Hunter2024!")) + } + + /** + * Each copy really is a variation of the rewrite, whatever the copies are to each + * other. What the class rules out is a relation that is nothing but a reused pair, + * which [leavesExactCopiesToTheReuseCheck] pins. + */ + @Test + fun keepsExactCopiesInsideAWiderFamily() = runTest { + val candidates = candidates("Password1", "Password1", "Password!") + + val relations = SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + + assertEquals(1, relations.size) + assertEquals(candidates.mapTo(mutableSetOf()) { it.id }, relations.single().relatedItemIds) + } + + @Test + fun ignoresPasswordsTooShortToCompare() = runTest { + assertEquals(emptySet(), flag("ab1", "ab2")) + } + + @Test + fun ignoresAnUnrelatedPair() = runTest { + assertEquals(emptySet(), flag("Thistle2019#", "b7Kq-vn2LpXw")) + } + + // endregion + + // region a shared part an attacker gets for free is not evidence + + @Test + fun ignoresTwoAccountsOnTheSameSite() = runTest { + assertEquals(emptySet(), flag("sparkasse-Cf9ZhV", "sparkasse-N4KEz")) + } + + @Test + fun ignoresAWordSharedWithAMuchShorterPassword() = runTest { + assertEquals(emptySet(), flag("Harbor&94", "Harbor2013!")) + } + + @Test + fun ignoresAWordSharedWithAPassphrase() = runTest { + assertEquals(emptySet(), flag("Golden!71", "golden.penguin.bobcat.heather")) + } + + @Test + fun ignoresAHabitualSuffixOverUnrelatedWords() = runTest { + assertEquals(emptySet(), flag("Baseballviolet%41", "Parsleyviolet%41")) + } + + // endregion + + // region a word is free however it is spelled + // + // The plain spelling of each of these is already quiet. Respelling the word must not + // turn it back into evidence: leetspeak, inner capitals and shouting are what a + // rule-based cracker undoes first, so they cost an attacker nothing. + + @Test + fun ignoresAWordSharedInLeetSpelling() = runTest { + assertEquals(emptySet(), flag("Dr@gon2019!!", "Dr@gon94##")) + } + + @Test + fun ignoresAWordSharedInMixedCase() = runTest { + assertEquals(emptySet(), flag("PenGuIn2019_", "PenGuInCanyon1993-")) + } + + @Test + fun ignoresAWordSharedInCapitals() = runTest { + assertEquals(emptySet(), flag("HARBOR&94", "HARBOR2013!")) + } + + // endregion + + // region the difference has to be worth something too + + @Test + fun ignoresTwoAccountsOnTheSameSiteBehindShortTokens() = runTest { + assertEquals(emptySet(), flag("facebook_Kd8!", "facebook_Xq2!")) + } + + @Test + fun ignoresTwoDifferentWordsBehindTheSameCounter() = runTest { + assertEquals(emptySet(), flag("garden22", "harden22")) + } + + @Test + fun ignoresAWordWhoseYearAndSymbolBothMoved() = runTest { + assertEquals(emptySet(), flag("Vanilla2000+", "Vanilla2007#")) + } + + // endregion + + private fun assertGrouped(expected: Set, actual: Set>) = + assertEquals(setOf(expected), actual) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt new file mode 100644 index 000000000..545a6d4cd --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt @@ -0,0 +1,638 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import java.util.UUID +import kotlin.random.Random + +/** + * Labelled password corpora for measuring [SimilarPasswordChecker]. + * + * The label that matters is [CorpusEntry.family]: two entries share a family when one + * was built from the other by a transformation that keeps the unpredictable part of + * the password, so cracking one hands you the other. Everything else is a negative, + * including entries that share only public or predictable structure -- a year, a site + * name, a dictionary word, a habitual symbol suffix. + * + * Two shapes are generated. [vaults] models what the checker actually runs against: a + * person's vault, where a small personal vocabulary makes unrelated passwords look + * alike. [singleVault] is one big pile with a wide vocabulary, used for timing. + */ +internal data class CorpusEntry( + val id: ItemId, + val password: String, + /** Shared secret material. Negative values are unique to the entry. */ + val family: Int, + val archetype: String, + /** How this entry was derived: "base", a variant name, "duplicate" or "independent". */ + val relation: String, + /** Shared surface structure, for attributing false positives. */ + val cluster: String, + /** Transformation steps away from the family base. */ + val distanceFromBase: Int, +) + +/** Unordered pair of item ids. */ +internal data class PairKey(val a: ItemId, val b: ItemId) { + companion object { + fun of(first: ItemId, second: ItemId): PairKey = + if (first < second) PairKey(first, second) else PairKey(second, first) + } +} + +internal class Corpus(val entries: List) { + + val byId: Map = entries.associateBy { it.id } + + /** Pairs the checker is expected to flag: same secret material, different text. */ + val variantPairs: Set + + /** Same secret material and identical text. Out of scope, ReusePasswordCheck owns these. */ + val duplicatePairs: Set + + init { + val groups = UnionFind(entries.size) + + entries.indices.filter { entries[it].family >= 0 } + .groupBy { entries[it].family } + .values + .forEach { members -> members.forEach { groups.union(members.first(), it) } } + + joinNearIdenticalText(groups) + + val variants = mutableSetOf() + val duplicates = mutableSetOf() + entries.indices.groupBy { groups.find(it) }.values.forEach { component -> + for (i in component.indices) for (j in i + 1..component.lastIndex) { + val first = entries[component[i]] + val second = entries[component[j]] + val key = PairKey.of(first.id, second.id) + if (first.password == second.password) duplicates += key else variants += key + } + } + variantPairs = variants + duplicatePairs = duplicates + } + + /** + * Joins entries the generator drew separately but that a cracker would hand over + * together anyway. + * + * Whether two passwords are related is a property of the passwords, not of how they + * came to exist. The generator works from a small vocabulary on purpose, and small + * vocabularies collide: sixteen keyboard walks and eleven symbols cannot make many + * distinct passwords. Labelling those unrelated marks a checker wrong for finding a + * pair that really does fall together, and at one point that single artifact was 39% + * of every false pair measured. + * + * One edit is the whole allowance, deliberately. Two leaves room for a coincidence a + * person could have arrived at twice ("Phoenix*10" beside "Phoenix*92"), and those + * stay negatives, which is what keeps this from quietly agreeing with whatever the + * checker happens to do. + */ + private fun joinNearIdenticalText(groups: UnionFind) { + val canonical = entries.map { it.password.toCharArray().canonicalize() } + val rows = DistanceRows(canonical.maxOfOrNull { it.size } ?: return) + val byLength = canonical.indices.groupBy { canonical[it].size } + + canonical.indices.forEach { index -> + for (length in canonical[index].size..canonical[index].size + NEAR_EDITS) + byLength[length].orEmpty().forEach { other -> + if (other > index && groups.find(other) != groups.find(index) && + boundedDistance( + canonical[index], + canonical[other], + NEAR_EDITS, + rows, + ) <= NEAR_EDITS + ) groups.union(index, other) + } + } + } + + private class UnionFind(size: Int) { + private val parent = IntArray(size) { it } + + fun find(node: Int): Int { + var root = node + while (parent[root] != root) root = parent[root] + var walk = node + while (parent[walk] != root) { + val next = parent[walk] + parent[walk] = root + walk = next + } + return root + } + + fun union(first: Int, second: Int) { + parent[find(first)] = find(second) + } + } + + private companion object { + /** Edits at which one password plainly gives the other away, however it was written. */ + const val NEAR_EDITS = 1 + } + + fun candidates(): List = entries.map { + PasswordCandidate( + id = it.id, + title = it.archetype, + username = null, + score = PasswordScore.Moderate, + password = it.password.toCharArray(), + urls = emptyList(), + ) + } +} + +internal object SimilarityCorpus { + + /** Site names the corpus uses, so a report can tell a public token from a secret one. */ + val siteNames: List get() = SITES + + /** Every word the corpus builds passwords from, for the same reason. */ + val vocabulary: Set by lazy { + (WORDS + PSEUDO_WORDS + COMMON_WEAK + KEYBOARD_WALKS + SITES).toSet() + } + + /** A wordlist to draw passphrases from, of the order a real one has. */ + val passphraseWords: List get() = WORDS + PSEUDO_WORDS + + /** + * A vault of [size] entries built the way one person builds passwords: a handful of + * favourite words, the accounts they actually hold, and their own habits about how + * much they reuse. + */ + fun vault(size: Int, random: Random, vocabularyScale: Double = 1.0): Corpus { + val vocabulary = personalVocabulary(random, vocabularyScale) + val accounts = SITES.shuffled(random).take(random.nextInt(8, 24)) + return CorpusBuilder( + random = random, + words = vocabulary, + sites = accounts, + variantShare = random.nextDouble(0.05, 0.45), + duplicateShare = random.nextDouble(0.0, 0.15), + hardNegativeShare = 0.10, + ).build(size) + } + + /** + * [vocabularyScale] widens the personal vocabulary: 1.0 is a person with a dozen + * favourite words, 4.0 someone who rarely repeats one. It is the single assumption + * the false positive rate is most sensitive to, so it is a knob rather than a + * constant. + */ + fun vaults( + count: Int, + seed: Long = 20260919L, + sizes: IntRange = 20..400, + vocabularyScale: Double = 1.0, + ): List { + val random = Random(seed) + return List(count) { + // Small vaults are the common case, so skew towards the low end. + val size = minOf( + sizes.last, + sizes.first + (random.nextDouble() * random.nextDouble() * + (sizes.last - sizes.first)).toInt(), + ) + vault(size, random, vocabularyScale) + } + } + + /** One undifferentiated pile, for timing the pairwise sweep. */ + fun singleVault(size: Int, seed: Long = 20260919L): Corpus { + val random = Random(seed) + return CorpusBuilder( + random = random, + words = WORDS + PSEUDO_WORDS, + sites = SITES, + variantShare = 0.24, + duplicateShare = 0.05, + hardNegativeShare = 0.14, + ).build(size) + } + + private fun personalVocabulary(random: Random, scale: Double): List { + fun widen(low: Int, high: Int) = random.nextInt( + (low * scale).toInt().coerceAtLeast(1), + (high * scale).toInt().coerceAtLeast(2), + ) + + val favourites = WORDS.shuffled(random).take(widen(6, 13)) + val rest = PSEUDO_WORDS.shuffled(random).take(widen(4, 10)) + // Favourites are picked more often than the rest, the way a habit works. + return favourites + favourites + rest + } + + // region vocabulary + + private val WORDS = listOf( + "summer", "winter", "spring", "autumn", "dragon", "monkey", "sunshine", "princess", + "football", "baseball", "mustang", "shadow", "master", "jordan", "harley", "ranger", + "hunter", "buster", "soccer", "hockey", "thunder", "falcon", "phoenix", "marmot", + "thistle", "garden", "canyon", "lantern", "puzzle", "otter", "badger", "walnut", + "copper", "silver", "golden", "purple", "orange", "violet", "indigo", "crimson", + "amber", "jasper", "quartz", "granite", "marble", "cobalt", "nickel", "carbon", + "oxygen", "helium", "argon", "kepler", "orion", "sirius", "nebula", "comet", + "meteor", "galaxy", "cosmos", "saturn", "jupiter", "mercury", "neptune", "titan", + "europa", "rocket", "anchor", "harbor", "compass", "seagull", "dolphin", "narwhal", + "penguin", "walrus", "tiger", "leopard", "cheetah", "panther", "jaguar", "cougar", + "bobcat", "coyote", "raven", "sparrow", "heron", "egret", "pelican", "osprey", + "kestrel", "condor", "vulture", "crane", "stork", "maple", "cedar", "birch", + "aspen", "willow", "poplar", "spruce", "juniper", "laurel", "myrtle", "hazel", + "alder", "rowan", "hawthorn", "bramble", "clover", "meadow", "prairie", "tundra", + "glacier", "summit", "ridge", "hollow", "boulder", "pebble", "cinder", "ember", + "kindle", "beacon", "mantle", "keystone", "archway", "cellar", "attic", + "bureau", "satchel", "kettle", "skillet", "saucer", "goblet", "tankard", "flagon", + "banner", "pennant", "cobble", "gravel", "thicket", "bracken", "heather", "gorse", + "nettle", "sorrel", "fennel", "parsley", "saffron", "cumin", "pepper", "ginger", + "cinnamon", "vanilla", "almond", "cashew", "pecan", "chestnut", "acorn", "cypress", + ) + + /** + * Pronounceable filler so a large corpus does not force unrelated passwords to share + * a word just because the word list ran out. They behave like real words everywhere + * the checker looks: all lowercase letters, spelled the way [WordModel] expects. + */ + private val PSEUDO_WORDS: List = buildSet { + val random = Random(7L) + val onsets = listOf( + "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", + "w", "z", "br", "cr", "dr", "fl", "gl", "pl", "sh", "st", "tr", "cl", "sp", "sn", + ) + val nuclei = listOf("a", "e", "i", "o", "u", "ai", "ea", "ee", "oo", "ou", "au", "ie") + val codas = listOf("", "n", "r", "l", "s", "t", "m", "k", "d", "ng", "rd", "ll", "nt") + while (size < 4_000) { + val word = (1..random.nextInt(2, 4)).joinToString("") { + onsets.random(random) + nuclei.random(random) + codas.random(random) + } + if (word.length in 4..11) add(word) + } + }.toList() + + private val SITES = listOf( + "facebook", "twitter", "google", "amazon", "netflix", "spotify", "github", "gitlab", + "reddit", "linkedin", "instagram", "tiktok", "dropbox", "slack", "discord", "steam", + "paypal", "stripe", "chase", "barclays", "revolut", "sparkasse", "comdirect", "ebay", + "etsy", "shopify", "zalando", "mediamarkt", "lidl", "rewe", "booking", "airbnb", + "expedia", "ryanair", "lufthansa", "bahn", "uber", "doordash", "deliveroo", "audible", + "kindle", "hulu", "disney", "twitch", "youtube", "vimeo", "notion", "figma", + "atlassian", "cloudflare", "namecheap", "hetzner", "digitalocean", "protonmail", + ) + + private val COMMON_WEAK = listOf( + "password", "password1", "password123", "123456", "1234567", "12345678", "123456789", + "qwerty", "qwerty123", "letmein", "iloveyou", "welcome", "monkey123", "abc123", + "trustno1", "dragon123", "sunshine1", "princess1", "football1", "starwars", + "whatever", "changeme", "admin123", "passw0rd", "qazwsx", "zaq12wsx", "michael1", + "superman1", "batman123", "pokemon1", "minecraft", "liverpool", "arsenal1", + "chelsea1", "barcelona", "juventus", "ferrari1", "porsche911", "corvette", + ) + + private val KEYBOARD_WALKS = listOf( + "qwertyuiop", "asdfghjkl", "zxcvbnm123", "1qaz2wsx", "2wsx3edc", "3edc4rfv", + "qazwsxedc", "1q2w3e4r", "2w3e4r5t", "qwerasdf", "asdfzxcv", "poiuytrewq", + "mnbvcxz123", "1234qwer", "4rfv5tgb", "zaqxswcde", + ) + + private val SYMBOLS = listOf('!', '@', '#', '%', '&', '*', '?', '-', '_', '+', '$') + private val SEPARATORS = listOf('-', '_', '.', ' ') + private const val TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private const val STRONG_ALPHABET = TOKEN_ALPHABET + "!@#%&*?-_+$" + + private val LEET_OUT = mapOf( + 'a' to '@', 'o' to '0', 'e' to '3', 's' to '$', + 'i' to '1', 't' to '7', 'b' to '8', 'g' to '9', + ) + + private val YEAR_PATTERN = Regex("(19|20)\\d{2}") + + private enum class Archetype(val label: String, val weight: Int) { + WordYearSymbol("word+year+symbol", 12), + WordSymbolDigits("word+symbol+digits", 10), + TwoWordSeparated("two-word", 12), + SitePrefixToken("site-prefix+token", 10), + TokenSiteToken("token+site+token", 5), + CommonWeak("common-weak", 5), + RandomStrong("random-strong", 20), + Passphrase("passphrase", 12), + LeetWordYear("leet word+year", 9), + KeyboardWalk("keyboard-walk", 5), + } + + private val ARCHETYPE_POOL = Archetype.entries.flatMap { a -> List(a.weight) { a } } + + /** + * Every transformation here keeps the part of the password an attacker cannot guess, + * so the result belongs in the same family as its input. + */ + private enum class VariantKind(val label: String) { + Leet("leet"), + CaseFlip("case-flip"), + YearBump("year-bump"), + CounterBump("counter-bump"), + SymbolSwap("symbol-swap"), + SiteSwap("site-swap"), + Typo("typo"), + AppendToken("append-token"), + } + + private enum class HardNegative(val label: String) { + /** Different words, same year and symbol. The year is all they share. */ + SharedYear("shared-year"), + + /** Two accounts on one site, unrelated secrets. */ + SameSiteDifferentSecret("same-site-different-secret"), + + /** Passphrases overlapping in one word out of four. */ + SharedPassphraseWord("shared-passphrase-word"), + + /** Unrelated bases behind the same habitual suffix. */ + SharedSuffix("shared-suffix"), + + /** The same favourite word inside different templates. */ + SharedWord("shared-word"), + + /** Different walks across the same keyboard. */ + KeyboardNeighbours("keyboard-neighbours"), + } + + // endregion + + private class CorpusBuilder( + private val random: Random, + private val words: List, + private val sites: List, + private val variantShare: Double, + private val duplicateShare: Double, + private val hardNegativeShare: Double, + ) { + + private val seen = HashSet() + private val entries = ArrayList() + private var family = 0 + + fun build(size: Int): Corpus { + var produced = 0 + while (produced < size * variantShare) produced += emitFamily() + + produced = 0 + while (produced < size * duplicateShare && entries.size < size) { + val (base, archetype) = fresh() + val id = family++ + emit(base, id, archetype, "base", "family-$id") + emit(base, id, archetype, "duplicate", "family-$id") + produced += 2 + } + + produced = 0 + while (produced < size * hardNegativeShare && entries.size < size) { + val cluster = HardNegative.entries.random(random) + hardNegative(cluster).forEach { (archetype, password) -> + emit(password, unique(), archetype, "independent", cluster.label) + produced++ + } + } + + while (entries.size < size) { + val (password, archetype) = fresh() + emit(password, unique(), archetype, "independent", "none") + } + + return Corpus(entries.take(size).shuffled(random)) + } + + private fun emitFamily(): Int { + val (base, archetype) = fresh() + val id = family++ + emit(base, id, archetype, "base", "family-$id") + var produced = 1 + + // A family is either one base with several rewrites, or a password that + // evolved step by step over the years. + val chained = random.nextInt(10) < 3 + var current = base + var steps = 0 + repeat(random.nextInt(1, 4)) { + val from = if (chained) current else base + val variant = applyVariant(from) ?: return@repeat + steps = if (chained) steps + 1 else 1 + emit(variant.second, id, archetype, variant.first, "family-$id", steps) + current = variant.second + produced++ + } + return produced + } + + private fun unique() = -(entries.size + 1) + + private fun emit( + password: String, + family: Int, + archetype: String, + relation: String, + cluster: String, + distanceFromBase: Int = 0, + ) { + entries += CorpusEntry( + id = UUID(0L, entries.size.toLong()), + password = password, + family = family, + archetype = archetype, + relation = relation, + cluster = cluster, + distanceFromBase = distanceFromBase, + ) + } + + private fun fresh(): Pair { + repeat(64) { + val archetype = ARCHETYPE_POOL.random(random) + val password = build(archetype) + if (seen.add(password)) return password to archetype.label + } + val fallback = build(Archetype.RandomStrong) + seen.add(fallback) + return fallback to Archetype.RandomStrong.label + } + + private fun word() = words.random(random) + + private fun capitalised() = word().replaceFirstChar { it.uppercase() } + + private fun year() = random.nextInt(1990, 2027).toString() + + private fun token(length: Int, alphabet: String = TOKEN_ALPHABET) = + String(CharArray(length) { alphabet[random.nextInt(alphabet.length)] }) + + private fun leet(text: String, count: Int): String { + val positions = text.indices.filter { text[it].lowercaseChar() in LEET_OUT } + if (positions.isEmpty()) return text + val chars = text.toCharArray() + positions.shuffled(random).take(count).forEach { + chars[it] = LEET_OUT.getValue(chars[it].lowercaseChar()) + } + return String(chars) + } + + private fun build(archetype: Archetype): String = when (archetype) { + Archetype.WordYearSymbol -> capitalised() + year() + SYMBOLS.random(random) + + Archetype.WordSymbolDigits -> + capitalised() + SYMBOLS.random(random) + random.nextInt(10, 100) + + Archetype.TwoWordSeparated -> { + val separator = SEPARATORS.random(random) + word() + separator + word() + separator + random.nextInt(10, 100) + } + + Archetype.SitePrefixToken -> + sites.random(random) + SEPARATORS.random(random) + token(random.nextInt(4, 7)) + + Archetype.TokenSiteToken -> { + val token = token(3) + val symbol = SYMBOLS.random(random) + "$token$symbol${sites.random(random)}$symbol$token" + } + + Archetype.CommonWeak -> COMMON_WEAK.random(random) + + Archetype.RandomStrong -> token(random.nextInt(16, 23), STRONG_ALPHABET) + + Archetype.Passphrase -> { + val separator = SEPARATORS.random(random) + (1..4).joinToString(separator.toString()) { word() } + } + + Archetype.LeetWordYear -> leet( + capitalised() + capitalised() + year() + SYMBOLS.random(random), + count = random.nextInt(1, 3), + ) + + Archetype.KeyboardWalk -> KEYBOARD_WALKS.random(random) + SYMBOLS.random(random) + } + + private fun applyVariant(password: String): Pair? { + repeat(16) { + val kind = VariantKind.entries.random(random) + val candidate = variant(kind, password) + if (candidate != null && candidate != password && candidate.length >= 4 && + seen.add(candidate) + ) return kind.label to candidate + } + return null + } + + private fun variant(kind: VariantKind, password: String): String? = when (kind) { + VariantKind.Leet -> leet(password, count = random.nextInt(1, 3)) + + VariantKind.CaseFlip -> { + val letters = password.indices.filter { password[it].isLetter() } + if (letters.isEmpty()) null else { + val chars = password.toCharArray() + letters.shuffled(random).take(random.nextInt(1, 3)).forEach { + chars[it] = if (chars[it].isUpperCase()) chars[it].lowercaseChar() + else chars[it].uppercaseChar() + } + String(chars) + } + } + + VariantKind.YearBump -> YEAR_PATTERN.find(password)?.let { + val bumped = it.value.toInt() + random.nextInt(1, 4) + password.replaceRange(it.range, bumped.toString()) + } + + VariantKind.CounterBump -> { + val trailing = password.takeLastWhile { it.isDigit() } + if (trailing.isEmpty()) password + random.nextInt(1, 10) + else password.dropLast(trailing.length) + (trailing.toLong() + 1) + } + + VariantKind.SymbolSwap -> password.lastOrNull()?.takeIf { it in SYMBOLS }?.let { last -> + password.dropLast(1) + SYMBOLS.filter { it != last }.random(random) + } + + VariantKind.SiteSwap -> sites.firstOrNull { password.contains(it, ignoreCase = true) } + ?.let { present -> + val others = sites.filter { it != present } + if (others.isEmpty()) null else password.replace(present, others.random(random)) + } + + VariantKind.Typo -> { + val chars = password.toCharArray() + val at = random.nextInt(chars.size) + when (random.nextInt(4)) { + 0 -> password.replaceRange(at, at + 1, TOKEN_ALPHABET.random(random).toString()) + 1 -> password.removeRange(at, at + 1) + 2 -> StringBuilder(password).insert(at, TOKEN_ALPHABET.random(random)) + .toString() + + else -> if (at + 1 < chars.size) { + chars[at] = password[at + 1] + chars[at + 1] = password[at] + String(chars) + } else null + } + } + + VariantKind.AppendToken -> password + token(random.nextInt(1, 4)) + } + + private fun hardNegative(cluster: HardNegative): List> { + val members = when (cluster) { + HardNegative.SharedYear -> { + val year = year() + val symbol = SYMBOLS.random(random) + List(random.nextInt(2, 5)) { + "word+year+symbol" to capitalised() + capitalised() + year + symbol + } + } + + HardNegative.SameSiteDifferentSecret -> { + val site = sites.random(random) + val separator = SEPARATORS.random(random) + List(random.nextInt(2, 4)) { + "site-prefix+token" to site + separator + token(random.nextInt(4, 7)) + } + } + + HardNegative.SharedPassphraseWord -> { + val shared = word() + val separator = SEPARATORS.random(random) + List(2) { + "passphrase" to (listOf(shared) + List(3) { word() }) + .joinToString(separator.toString()) + } + } + + HardNegative.SharedSuffix -> { + val suffix = "${SYMBOLS.random(random)}${random.nextInt(10, 100)}" + List(random.nextInt(2, 4)) { + "word+symbol+digits" to capitalised() + word() + suffix + } + } + + HardNegative.SharedWord -> { + val shared = word() + listOf( + "word+year+symbol" to shared.replaceFirstChar { it.uppercase() } + + year() + SYMBOLS.random(random), + "two-word" to shared + SEPARATORS.random(random) + word() + + random.nextInt(10, 100), + "site-prefix+token" to sites.random(random) + "_" + shared + token(3), + ) + } + + HardNegative.KeyboardNeighbours -> KEYBOARD_WALKS.shuffled(random).take(2).map { + "keyboard-walk" to it + SYMBOLS.random(random) + } + } + return members.filter { seen.add(it.second) } + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt new file mode 100644 index 000000000..1f3c596bd --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpusTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The corpus is the instrument every other measurement is read off, so what it calls a + * family has to be true of the passwords rather than true of how they were generated: a + * generator draws independently and still collides. + */ +class SimilarityCorpusTest { + + @Test + fun readsTwoIndependentEntriesOneEditApartAsOneFamily() { + val corpus = corpus("4rfv5tgb+" to -1, "4rfv5tgb&" to -2) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + /** Two edits apart is a coincidence a person could plausibly have arrived at twice. */ + @Test + fun leavesTwoIndependentEntriesTwoEditsApartUnrelated() { + val corpus = corpus("Phoenix*10" to -1, "Phoenix*92" to -2) + + assertEquals(emptySet(), corpus.variantPairs) + } + + /** Spelling is not distance: one of these is the other, written differently. */ + @Test + fun readsALeetRewriteAsTheSameFamily() { + val corpus = corpus("Summer2019!" to -1, "5umm3r2019!" to -2) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + /** Whoever is one edit from both of them relates the two. */ + @Test + fun joinsFamiliesThroughAnEntryThatSitsBetweenThem() { + val corpus = corpus( + "harbor-meadow-84" to 7, + "harbor-meadow-85" to -2, + "harbor-meadow-86" to -3, + ) + + assertEquals(setOf(pair(0, 1), pair(0, 2), pair(1, 2)), corpus.variantPairs) + } + + /** A declared family still holds together however far apart its members drifted. */ + @Test + fun keepsADeclaredFamilyThatNoDistanceWouldHaveFound() { + val corpus = corpus("Baseball2008?" to 3, "Baseball2012-" to 3) + + assertEquals(setOf(pair(0, 1)), corpus.variantPairs) + } + + @Test + fun countsIdenticalTextAsADuplicateRatherThanAVariation() { + val corpus = corpus("Summer2019!" to 3, "Summer2019!" to 3) + + assertEquals(emptySet(), corpus.variantPairs) + assertEquals(setOf(pair(0, 1)), corpus.duplicatePairs) + } + + private fun corpus(vararg entries: Pair) = Corpus( + entries.mapIndexed { index, (password, family) -> + CorpusEntry( + id = id(index), + password = password, + family = family, + archetype = "test", + relation = if (family >= 0) "base" else "independent", + cluster = "none", + distanceFromBase = 0, + ) + }, + ) + + private fun id(index: Int) = UUID(0L, index.toLong()) + + private fun pair(first: Int, second: Int) = PairKey.of(id(first), id(second)) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt new file mode 100644 index 000000000..0281b45e7 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilaritySensitivitySweep.kt @@ -0,0 +1,266 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +/** + * Two follow-ups to [SimilarPasswordCheckerBenchmark]: which of the checker's three rules + * produces the false pairs, and whether moving its constants can reach an operating point + * worth shipping. + * + * SIMILARITY_VAULTS=2000 ./gradlew :feature:password-health:testDebugUnitTest \ + * --tests '*SimilaritySensitivitySweep*' + * + * The report lands in `build/reports/similarity/sweep.txt`. + */ +class SimilaritySensitivitySweep { + + private val report = StringBuilder() + + /** The replica is only worth reading if it still agrees with the shipped checker. */ + @Test + fun replicaMatchesShippedChecker() = runTest(timeout = 1.hours) { + SimilarityCorpus.vaults(60, seed = 4242L).forEach { corpus -> + val candidates = corpus.candidates() + val shipped = HashSet() + SimilarPasswordChecker().check(candidates) + .findings + .filterIsInstance() + .forEach { finding -> + val ids = finding.relatedItemIds.toList() + for (i in ids.indices) for (j in i + 1..ids.lastIndex) + shipped += PairKey.of(ids[i], ids[j]) + } + assertEquals(shipped, TunableSimilarity().edges(candidates).keys) + } + } + + @Test + fun sweep() = runTest(timeout = 4.hours) { + val count = System.getenv("SIMILARITY_VAULTS")?.toIntOrNull() ?: 500 + val corpora = SimilarityCorpus.vaults(count) + val passwords = corpora.sumOf { it.entries.size } + + line("SimilarPasswordChecker sensitivity sweep") + line("$count simulated vaults, $passwords passwords") + line("") + + attribution(corpora) + + line("== how much the result depends on the vocabulary assumption ==") + header() + listOf( + 1.0 to "a dozen favourite words", + 2.0 to "twice as many", + 4.0 to "rarely repeats one" + ) + .forEach { (scale, label) -> + score( + "personal vocabulary x$scale ($label)", + TunableSimilarity(), + SimilarityCorpus.vaults(count, vocabularyScale = scale), + ) + } + line("") + + line("== moving the constants ==") + header() + settings().forEach { (label, similarity) -> score(label, similarity, corpora) } + line("") + + line("== finer grid ==") + header() + grid().forEach { (label, similarity) -> score(label, similarity, corpora) } + line("") + + // Exactly what the edit cap costs: the same checker with and without it. + detail("no edit cap", TunableSimilarity(maxDistance = Int.MAX_VALUE), corpora) + detail("current, cap 4", TunableSimilarity(), corpora) + + val out = File( + System.getenv("SIMILARITY_REPORT_DIR") ?: "build/reports/similarity", + "sweep.txt", + ) + out.parentFile?.mkdirs() + out.writeText(report.toString()) + println(report) + println("report written to ${out.absolutePath}") + } + + /** Which rule is responsible for the right answers and which for the wrong ones. */ + private fun attribution(corpora: List) { + val truePositives = HashMap() + val falsePositives = HashMap() + val examples = HashMap>() + + corpora.forEach { corpus -> + TunableSimilarity().edges(corpus.candidates()).forEach { (pair, rule) -> + when { + pair in corpus.variantPairs -> + truePositives[rule] = (truePositives[rule] ?: 0) + 1 + + pair in corpus.duplicatePairs -> Unit + + else -> { + falsePositives[rule] = (falsePositives[rule] ?: 0) + 1 + val bucket = examples.getOrPut(rule) { mutableListOf() } + if (bucket.size < 6) bucket += "%-30s %-30s".format( + corpus.byId.getValue(pair.a).password, + corpus.byId.getValue(pair.b).password, + ) + } + } + } + } + + line("== which rule fired ==") + line("%-14s %12s %12s %12s".format("rule", "TP", "FP", "precision")) + TunableSimilarity.Rule.entries.forEach { rule -> + val tp = truePositives[rule] ?: 0 + val fp = falsePositives[rule] ?: 0 + line("%-14s %12d %12d %12s".format(rule.name, tp, fp, pct(ratio(tp, tp + fp)))) + } + line("") + TunableSimilarity.Rule.entries.forEach { rule -> + line("-- false pairs from ${rule.name} --") + examples[rule].orEmpty().forEach { line(" $it") } + } + line("") + } + + /** Where the two live thresholds sit on their curves, so each value is a decision. */ + private fun grid(): List> = + listOf(0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7).map { share -> + "affixShare $share" to TunableSimilarity(affixShare = share) + } + listOf(3, 4, 5, 6, 8).map { cap -> + "edit budget capped at $cap" to TunableSimilarity(maxDistance = cap) + } + + /** Each part of the fix switched off on its own, to show what it is worth. */ + private fun settings(): List> = listOf( + "current" to TunableSimilarity(), + "edit rule asks for no evidence" to TunableSimilarity(requireEvidence = false), + "evidence needs 2 shared words" to TunableSimilarity(minSharedWords = 2), + "containment rule off" to TunableSimilarity(minContainedLength = 0), + "containment needs 12 characters" to TunableSimilarity(minContainedLength = 12), + "a number may move any distance" to TunableSimilarity(maxCounterStep = Long.MAX_VALUE), + "a number may move by 1" to TunableSimilarity(maxCounterStep = 1), + "a number may move by 10" to TunableSimilarity(maxCounterStep = 10), + "shared words buy half the budget" to TunableSimilarity(weakEvidenceDivisor = 2), + "shared words buy nothing" to TunableSimilarity(sharedWordsAsEvidence = Int.MAX_VALUE), + "word affixes count again" to TunableSimilarity(ignoreWordAffixes = false), + "measured against raw length" to + TunableSimilarity(denominator = TunableSimilarity.Denominator.Longer), + "measured against the shorter password" to + TunableSimilarity(denominator = TunableSimilarity.Denominator.Shorter), + "both reverted, old thresholds" to TunableSimilarity( + ignoreWordAffixes = false, + denominator = TunableSimilarity.Denominator.Shorter, + affixShare = 0.3, + wordAffixShare = 0.55, + ), + "current + minTemplateLength 10" to TunableSimilarity(minTemplateLength = 10), + "current + minAffixLength 5" to TunableSimilarity(minAffixLength = 5), + "current + maxDistanceRatio 6" to TunableSimilarity(maxDistanceRatio = 6), + ) + + private fun score(label: String, similarity: TunableSimilarity, corpora: List) { + var truePositives = 0L + var falsePositives = 0L + var truePairs = 0L + corpora.forEach { corpus -> + truePairs += corpus.variantPairs.size + similarity.edges(corpus.candidates()).keys.forEach { pair -> + when { + pair in corpus.variantPairs -> truePositives++ + pair in corpus.duplicatePairs -> Unit + else -> falsePositives++ + } + } + } + val precision = ratio(truePositives, truePositives + falsePositives) + val recall = ratio(truePositives, truePairs) + val f1 = if (precision + recall == 0.0) 0.0 + else 2 * precision * recall / (precision + recall) + line( + "%-46s %10s %10s %8.4f %12d".format( + label, pct(precision), pct(recall), f1, falsePositives, + ), + ) + } + + /** + * Recall variation by variation and by length, so a precision gain can be paid for + * knowingly. The length bucket is what shows where a rule that only bites on long + * passwords actually costs anything. + */ + private fun detail(label: String, similarity: TunableSimilarity, corpora: List) { + val byVariation = HashMap() + val byLength = HashMap() + val byShape = HashMap() + corpora.forEach { corpus -> + val flagged = similarity.edges(corpus.candidates()).keys + corpus.variantPairs.forEach { pair -> + val first = corpus.byId.getValue(pair.a) + val second = corpus.byId.getValue(pair.b) + val kind = when { + first.relation == "base" -> second.relation + second.relation == "base" -> first.relation + else -> "variant-of-variant" + } + val shorter = minOf(first.password.length, second.password.length) + val length = when { + shorter < 12 -> "under 12" + shorter < 16 -> "12 to 15" + shorter < 20 -> "16 to 19" + shorter < 28 -> "20 to 27 (cap bites)" + else -> "28 and over (cap bites)" + } + val hit = pair in flagged + byVariation.tally(kind, hit) + byLength.tally(length, hit) + byShape.tally(first.archetype, hit) + } + } + listOf( + "variation" to byVariation, + "length of the shorter password" to byLength, + "shape" to byShape, + ).forEach { (name, buckets) -> + line("-- recall by $name, $label --") + buckets.entries.sortedBy { it.key }.forEach { (kind, bucket) -> + line( + "%-26s %7d/%-7d %s".format( + kind, bucket[0], bucket[1], pct(ratio(bucket[0], bucket[1])), + ), + ) + } + } + line("") + } + + private fun HashMap.tally(key: String, hit: Boolean) { + val bucket = getOrPut(key) { LongArray(2) } + bucket[1]++ + if (hit) bucket[0]++ + } + + private fun header() = line( + "%-46s %10s %10s %8s %12s".format("setting", "precision", "recall", "F1", "false pairs"), + ) + + private fun line(text: String) { + report.append(text).append('\n') + } + + private fun ratio(numerator: Number, denominator: Number): Double { + val d = denominator.toDouble() + return if (d == 0.0) 0.0 else numerator.toDouble() / d + } + + private fun pct(value: Double) = "%.2f%%".format(value * 100) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt new file mode 100644 index 000000000..eccf1c283 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/TunableSimilarity.kt @@ -0,0 +1,207 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * A copy of [SimilarPasswordChecker]'s decision with its constants opened up, so the + * operating point can be moved and measured without touching the shipped checker. + * + * Only the decision is duplicated. Everything it leans on (canonicalisation, the + * character classification in [PasswordShape], the word model, the bounded distance) is + * the real code from PasswordSimilarity.kt, and + * `SimilaritySensitivitySweep.replicaMatchesShippedChecker` pins the default settings to + * the shipped output, so a drift in either one fails. + */ +internal class TunableSimilarity( + private val minLength: Int = 6, + private val minTemplateLength: Int = 8, + private val minAffixLength: Int = 4, + /** Zero switches the containment rule off entirely. */ + private val minContainedLength: Int = 8, + /** How far a number may travel and still read as a bump. Int.MAX_VALUE switches it off. */ + private val maxCounterStep: Long = 3, + private val minSharedWords: Int = 1, + private val sharedWordsAsEvidence: Int = 2, + /** What shared words alone are worth. 1 is the old behaviour: the full budget. */ + private val weakEvidenceDivisor: Int = 1, + private val affixShare: Double = 0.4, + private val wordAffixShare: Double = 0.55, + private val maxDistanceRatio: Int = 4, + /** A ceiling on the edit budget however long the passwords are. */ + private val maxDistance: Int = 4, + private val denominator: Denominator = Denominator.LongerSecret, + /** Whether an end that reads as words on both sides stops counting entirely. */ + private val ignoreWordAffixes: Boolean = true, + /** + * Whether a pair the edit budget accepts still has to share something, or differ only + * in free filler, before it is reported. Switching this off is what the edit rule used + * to do, and it produced most of the false pairs. + */ + private val requireEvidence: Boolean = true, + /** + * Whether the two ends are stopped from claiming the same characters twice. Without it + * a short password whose prefix and suffix overlap can score above 1.0 on its own + * length alone. + */ + private val capOverlap: Boolean = false, +) { + + enum class Denominator { Shorter, Longer, Mean, LongerSecret } + + /** Which rule fired for a flagged pair. */ + enum class Rule { LeetFamily, Contains, Template, Distance } + + fun edges(candidates: List): Map { + val shapes = candidates.map { it to PasswordShape(it.password) } + val comparable = shapes.filter { (_, shape) -> shape.size >= minLength } + val edges = LinkedHashMap() + + comparable.groupBy { (_, shape) -> CharArrayKey(shape.canonical) } + .values + .filter { family -> family.distinctBy { CharArrayKey(it.first.password) }.size > 1 } + .forEach { family -> + for (i in family.indices) for (j in i + 1..family.lastIndex) + edges[PairKey.of(family[i].first.id, family[j].first.id)] = Rule.LeetFamily + } + + val rows = DistanceRows(comparable.maxOfOrNull { (_, shape) -> shape.size } ?: 0) + for (i in comparable.indices) { + val (first, firstShape) = comparable[i] + for (j in i + 1..comparable.lastIndex) { + val (second, secondShape) = comparable[j] + if (first.password.contentEquals(second.password)) continue + if (firstShape.canonical.contentEquals(secondShape.canonical)) continue + + val rule = rule(firstShape, secondShape, rows) + if (rule != null) edges.putIfAbsent(PairKey.of(first.id, second.id), rule) + } + } + return edges + } + + private fun rule(first: PasswordShape, second: PasswordShape, rows: DistanceRows): Rule? { + val shorter = min(first.size, second.size) + val prefix = commonPrefixLength(first.canonical, second.canonical) + val suffix = commonSuffixLength(first.canonical, second.canonical) + + if (minContainedLength > 0 && extendsTheOther(first, second, prefix, suffix)) + return Rule.Contains + if (sharesTemplate(first, second, prefix, suffix, shorter)) return Rule.Template + + val allowed = min(max(1, shorter / maxDistanceRatio), maxDistance) + val budget = if (requireEvidence) editBudget(first, second, prefix, suffix, allowed) + else allowed + if (budget <= 0) return null + + return if (boundedDistance(first.canonical, second.canonical, budget, rows) <= budget) + Rule.Distance + else null + } + + private fun extendsTheOther( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + ): Boolean { + val shorter = min(first.size, second.size) + if (shorter < minContainedLength) return false + if (first.size == second.size) return false + + val longer = if (first.size > second.size) first else second + if (prefix >= shorter) return longer.startsRun(shorter) + if (suffix >= shorter) return longer.startsRun(longer.size - shorter) + return false + } + + private fun editBudget( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + allowed: Int, + ): Int { + val firstEnd = max(prefix, first.size - suffix) + val secondEnd = max(prefix, second.size - suffix) + val before = first.numberAround(prefix, firstEnd) + val after = second.numberAround(prefix, secondEnd) + if (before >= 0 && after >= 0 && abs(before - after) > maxCounterStep) return 0 + + val shared = prefixAffix(first, second, prefix).length + + suffixAffix(first, second, suffix).length + if (shared >= minAffixLength) return allowed + + val words = min(sharedWords(first, prefix, suffix), sharedWords(second, prefix, suffix)) + if (words < minSharedWords) return 0 + + val oneStep = first.holdsNothingToCrack(prefix, firstEnd) && + second.holdsNothingToCrack(prefix, secondEnd) || + first.movesOneSlot(prefix, firstEnd) && second.movesOneSlot(prefix, secondEnd) + if (oneStep) return allowed + + return if (words >= sharedWordsAsEvidence) max(1, allowed / weakEvidenceDivisor) else 0 + } + + private fun sharedWords(shape: PasswordShape, prefix: Int, suffix: Int): Int { + val tail = max(prefix, shape.size - suffix) + return shape.wordsWithin(0, prefix) + shape.wordsWithin(tail, shape.size) + } + + /** One shared end: what it is worth, and whether it reads as words on both sides. */ + private class Affix(val length: Int, val isWord: Boolean) + + private fun prefixAffix(first: PasswordShape, second: PasswordShape, prefix: Int): Affix { + val isWord = first.readsAsWords(0, prefix) && second.readsAsWords(0, prefix) + val length = if (ignoreWordAffixes && isWord) 0 + else min(first.secretLength(0, prefix), second.secretLength(0, prefix)) + return Affix(length, isWord) + } + + private fun suffixAffix(first: PasswordShape, second: PasswordShape, suffix: Int): Affix { + val isWord = first.readsAsWords(first.size - suffix, first.size) && + second.readsAsWords(second.size - suffix, second.size) + val length = if (ignoreWordAffixes && isWord) 0 + else min( + first.secretLength(first.size - suffix, first.size), + second.secretLength(second.size - suffix, second.size), + ) + return Affix(length, isWord) + } + + private fun sharesTemplate( + first: PasswordShape, + second: PasswordShape, + prefix: Int, + suffix: Int, + shorter: Int, + ): Boolean { + if (shorter < minTemplateLength) return false + + val head = prefixAffix(first, second, prefix) + val tail = suffixAffix(first, second, suffix) + // Zeroed before the length gate, the way the checker does it: an end that counts + // for nothing must not carry the pair past the minimum either. + val tailLength = if (capOverlap) min(tail.length, shorter - min(head.length, shorter)) + else tail.length + if (head.length + tailLength < minAffixLength) return false + + val longer = if (first.size >= second.size) first else second + val scale = when (denominator) { + Denominator.Shorter -> shorter.toDouble() + Denominator.Longer -> max(first.size, second.size).toDouble() + Denominator.Mean -> (first.size + second.size) / 2.0 + Denominator.LongerSecret -> longer.secretLength(0, longer.size).toDouble() + } + return contribution(head.length, scale, head.isWord) + + contribution(tailLength, scale, tail.isWord) >= 1.0 + } + + private fun contribution(length: Int, scale: Double, isWord: Boolean): Double = when { + isWord && ignoreWordAffixes -> 0.0 + scale <= 0.0 -> if (length > 0) Double.MAX_VALUE else 0.0 + else -> length / ((if (isWord) wordAffixShare else affixShare) * scale) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt new file mode 100644 index 000000000..c124a8686 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WordModelTest.kt @@ -0,0 +1,154 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The model replaced a list of English words, so the tests that matter are the ones a + * list could not have passed: a German, French or Spanish word it was never taught. + */ +class WordModelTest { + + private fun wordAt(text: String, from: Int = 0): Int { + val raw = text.toCharArray() + return WordModel.wordRunAt(raw.canonicalize(), raw, from, raw.size) + } + + // region words a list would have held + + @Test + fun readsAPlainEnglishWord() = assertEquals(6, wordAt("dragon")) + + @Test + fun readsAShoutedWord() = assertEquals(6, wordAt("HARBOR")) + + @Test + fun readsALeetedWord() = assertEquals(6, wordAt("Dr@gon")) + + // endregion + + // region words no English list holds + + @Test + fun readsAGermanWordItWasNeverTaught() = assertEquals(9, wordAt("kartoffel")) + + @Test + fun readsALeetedGermanWord() = assertEquals(9, wordAt("K@rtoffel")) + + @Test + fun readsAFrenchWordItWasNeverTaught() = assertEquals(8, wordAt("papillon")) + + @Test + fun readsAnItalianWordItWasNeverTaught() = assertEquals(8, wordAt("farfalla")) + + /** + * How much of a word survives, language by language, which is the claim the whole + * change rests on. Reading a word most of the way through is enough for what the + * checker asks; reading it whole matters only for counting words. + * + * Measured: English 100%, Spanish 100%, French 96%, Italian 95%, German 76%, Polish + * 61%. Polish is the floor because its spelling is the furthest from the training set. + * The English list this replaced read 0% of every word below that is not English. + */ + @Test + fun readsMostOfAWordInEveryLanguageAroundEnglish() { + val byLanguage = mapOf( + "english" to listOf( + "dragon", "harbor", "summer", "monkey", "sunshine", "butterfly", + "freedom", "mountain", "chocolate", "elephant", "whisper", "garden", + ), + "german" to listOf( + "kartoffel", "sonnenschein", "freiheit", "schmetterling", "blumenwiese", + "hoffnung", "wasserfall", "geburtstag", "fernweh", "morgenrot", + ), + "french" to listOf( + "papillon", "bonjour", "liberte", "chocolat", "printemps", "bibliotheque", + "tournesol", "pamplemousse", "brouillard", "chaussure", + ), + "spanish" to listOf( + "mariposa", "primavera", "libertad", "corazon", "ventana", "estrella", + "murcielago", "zanahoria", "esperanza", "caballero", + ), + "italian" to listOf( + "farfalla", "amicizia", "montagna", "girasole", "biblioteca", + "cioccolato", "finestra", "speranza", "tramonto", + ), + "polish" to listOf( + "przyjaciel", "wolnosc", "motyl", "sloneczny", "biblioteka", "marzenie", + ), + ) + + byLanguage.forEach { (language, words) -> + val read = words.sumOf { wordAt(it) }.toDouble() / words.sumOf { it.length } + assertTrue(read >= 0.60, "only ${"%.0f".format(read * 100)}% of $language read") + } + } + + // endregion + + // region what an attacker still has to crack + + @Test + fun rejectsAShortRandomToken() = assertEquals(0, wordAt("Kd8!")) + + @Test + fun rejectsALongerRandomToken() = assertEquals(0, wordAt("b7Kqxz")) + + @Test + fun rejectsAKeyboardWalk() = assertEquals(0, wordAt("4rfv5tgb")) + + @Test + fun rejectsARunTooShortToBeAWord() = assertEquals(0, wordAt("ab")) + + /** "1337" canonicalises to "ieet". One real letter is what tells them apart. */ + @Test + fun rejectsDigitsThatFoldIntoLetters() = assertEquals(0, wordAt("1337")) + + /** + * What a model costs that a list did not: "trewq" is spelled the way words are and is + * not one. The price is paid back by every word of every language a list never held, + * and `SimilarPasswordCheckerBenchmark` weighs the two against each other. + */ + @Test + fun admitsASpellingThatIsNotAWord() = assertEquals(5, wordAt("trewq")) + + // endregion + + // region where a word ends + + @Test + fun stopsWhereTheLettersStop() = assertEquals(6, wordAt("dragon2019")) + + @Test + fun readsTheWordStartingAtTheOffsetItIsGiven() = assertEquals(6, wordAt("2019dragon", from = 4)) + + /** + * Canonicalising turns "1993+" into five more letters, so the run is eleven long and + * only the model says where the word inside it stopped. + */ + @Test + fun stopsWhereTheWordEndsInsideALongerRunOfLetters() = + assertEquals(6, wordAt("Harbor1993+")) + + /** + * The other thing a model costs. Two words written without anything between them read + * as one long word, because only a list knew where the first one ended. + * + * The right answer about cost, since all twelve characters are free either way, and + * the wrong answer about count: `PasswordShape.wordsWithin` sees one word where a + * person wrote two. That is why [PasswordShape] tries shape before the model at all, + * since "MeadowMeadow" as a person would usually write it splits on the capital. + */ + @Test + fun readsTwoWordsRunTogetherAsOne() = assertEquals(12, wordAt("meadowmeadow")) + + /** + * A run written in an alphabet the model cannot score is still letters in a row, and + * letters in a row in a password are a word far more often than they are a secret. + */ + @Test + fun keepsARunItHasNoWayToScore() = assertEquals(6, wordAt("пароль")) + + // endregion +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt new file mode 100644 index 000000000..97b0780e9 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt @@ -0,0 +1,68 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A group is one connected component over every relation edge, so a handful of copies + * can sit inside a much larger similarity chain. What the card says about it has to be + * what is mostly true of it. + */ +class RelatedGroupTest { + + private val ids = List(6) { UUID(0L, it.toLong()) } + + @Test + fun namesTheRelationThatCoversMostOfTheGroup() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1]), + relation(RelationType.Similar, ids[1], ids[2], ids[3], ids[4], ids[5]), + ) + + assertEquals(RelationType.Similar, group.dominantRelation) + } + + @Test + fun namesReuseWhenReuseIsWhatCoversTheGroup() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1], ids[2], ids[3]), + relation(RelationType.Similar, ids[3], ids[4]), + ) + + assertEquals(RelationType.Reused, group.dominantRelation) + } + + /** A tie goes to the finding worth acting on first. */ + @Test + fun namesTheGraverRelationWhenBothCoverTheSameCount() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1], ids[2]), + relation(RelationType.Similar, ids[3], ids[4], ids[5]), + ) + + assertEquals(RelationType.Reused, group.dominantRelation) + } + + @Test + fun keepsTheSeverityOfTheGravestRelationInIt() { + val group = group( + relation(RelationType.Reused, ids[0], ids[1]), + relation(RelationType.Similar, ids[1], ids[2], ids[3], ids[4], ids[5]), + ) + + assertEquals(FindingSeverity.High, group.maxSeverity) + } + + private fun group(vararg relations: HealthFinding.Relation) = RelatedGroup( + id = ids.first(), + members = ids.mapTo(mutableSetOf()) { + ItemHealth(itemId = it, title = "item", username = null, issues = emptyList()) + }, + relations = relations.toSet(), + ) + + private fun relation(type: RelationType, vararg members: ItemId) = + HealthFinding.Relation(relatedItemIds = members.toSet(), type = type) +} From 15750fb7ed362b4593d1113e5b9c0b10ce1a6873 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 21 Sep 2026 18:20:09 +0200 Subject: [PATCH 16/54] feat: add open item support --- .../presentation/navigation/EntryProvider.kt | 10 ++++- .../navigation/AppNavigatorTest.kt | 23 ++++++++++ .../presentation/PasswordHealthContent.kt | 4 ++ .../presentation/PasswordHealthEntries.kt | 5 ++- .../presentation/PasswordHealthScreen.kt | 13 +++++- .../presentation/PasswordHealthViewModel.kt | 10 +++++ .../presentation/SegmentContainerColor.kt | 3 ++ .../presentation/component/ItemHealthRow.kt | 9 +++- .../component/NeedsAttentionSection.kt | 9 +++- .../PasswordHealthDetailPlaceholder.kt | 42 +++++++++++++++++++ .../presentation/model/PasswordHealthEvent.kt | 8 ++++ .../model/PasswordHealthUiEvent.kt | 2 + .../src/main/res/values/strings.xml | 2 + 13 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt index 018878005..e21484c19 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -29,6 +31,7 @@ import de.davis.keygo.feature.item.create.presentation.totp.AssignTotpRoute import de.davis.keygo.feature.item.create.presentation.totp.assignTotpEntries import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute import de.davis.keygo.feature.onboarding.presentation.onboardingEntries +import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthDetailPlaceholder import de.davis.keygo.feature.password_health.presentation.passwordHealthEntries import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.settingsEntries @@ -39,6 +42,7 @@ import de.davis.keygo.item.dialog.SelectItemContent private const val TAG = "KeyGoEntryProvider" +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> NavEntry { val activity = LocalActivity.current @@ -132,7 +136,11 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> } passwordHealthEntries( - metadata = NavigationOnly, + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { PasswordHealthDetailPlaceholder() }, + ) + NavigationOnly, + openItemId = (navigator.state.openDetail as? RouteDestination.ViewItem)?.id, + openItem = { itemId -> navigator.showDetail(RouteDestination.ViewItem(itemId)) }, ) } } diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt index e38e2eb3d..5d5d01fd5 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -8,6 +8,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.presentation.model.RouteDestination import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute +import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.SettingsRoute import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute @@ -208,6 +209,27 @@ class AppNavigatorTest { ) } + @Test + fun `an item opened from the health report lands beside it, not on the home stack`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.navigate(PasswordHealthRoute) + + navigator.showDetail(RouteDestination.ViewItem(itemId)) + + assertEquals( + listOf(PasswordHealthRoute, RouteDestination.ViewItem(itemId)), + navigator.shown, + ) + assertEquals( + listOf(RouteDestination.Home), + navigator.state.backStacks.getValue(RouteDestination.Home).toList(), + ) + + navigator.goBack() + assertEquals(listOf(PasswordHealthRoute), navigator.shown) + } + @Test fun `editing stacks on the item it edits, so back returns to it`() { val navigator = unlocked() @@ -571,6 +593,7 @@ class AppNavigatorTest { private companion object { val TOP_LEVEL_ROUTES: Set = linkedSetOf( RouteDestination.Home, + PasswordHealthRoute, RouteDestination.Connectivity, SettingsRoute, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index 3dc1ed43c..400540430 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.ui.clipboard.setText @@ -46,6 +47,7 @@ import kotlinx.coroutines.launch @Composable internal fun PasswordHealthContent( state: PasswordHealthUiState, + openItemId: ItemId?, onEvent: (PasswordHealthUiEvent) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -88,6 +90,7 @@ internal fun PasswordHealthContent( if (state.status == PasswordHealthStatus.NEEDS_ATTENTION) needsAttentionSection( sections = state.healthSections, pendingFix = state.pendingFix, + openItemId = openItemId, onEvent = onEvent, ) @@ -141,6 +144,7 @@ private fun PasswordHealthContentPreview() { ) ), ), + openItemId = null, onEvent = {}, ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt index 2ec20ff7c..29dc5eac2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.password_health.presentation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.item.domain.alias.ItemId import kotlinx.serialization.Serializable @Serializable @@ -9,8 +10,10 @@ object PasswordHealthRoute : NavKey fun EntryProviderScope.passwordHealthEntries( metadata: Map = emptyMap(), + openItemId: ItemId?, + openItem: (ItemId) -> Unit, ) { entry(metadata = metadata) { - PasswordHealthScreen() + PasswordHealthScreen(openItemId = openItemId, openItem = openItem) } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt index 6c98c1fa4..72b168ca5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthScreen.kt @@ -4,11 +4,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import org.koin.androidx.compose.koinViewModel @Composable -fun PasswordHealthScreen() { +fun PasswordHealthScreen(openItemId: ItemId?, openItem: (ItemId) -> Unit) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -16,8 +20,15 @@ fun PasswordHealthScreen() { viewModel.onEvent(PasswordHealthUiEvent.RunHealthCheck) } + ObserveAsEvents(flow = viewModel.events) { + when (it) { + is PasswordHealthEvent.OpenItem -> openItem(it.itemId) + } + } + PasswordHealthContent( state = state, + openItemId = openItemId.takeUnless { LocalIsInSinglePaneMode.current }, onEvent = viewModel::onEvent, ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index eb1dc7417..576f62a27 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -16,13 +16,16 @@ import de.davis.keygo.feature.password_health.domain.repository.BreachCheckState import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow 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 @@ -36,6 +39,9 @@ internal class PasswordHealthViewModel( private val websiteHandler: WebsiteHandler, ) : ViewModel() { + private val _eventChannel = Channel(Channel.BUFFERED) + val events = _eventChannel.receiveAsFlow() + private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) val uiState = combine( _base, @@ -55,6 +61,10 @@ internal class PasswordHealthViewModel( PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck(RunPhase.Refresh) + is PasswordHealthUiEvent.ItemClicked -> _eventChannel.trySend( + PasswordHealthEvent.OpenItem(event.itemId) + ) + is PasswordHealthUiEvent.OnBreachCheckChanged -> viewModelScope.launch { breachCheckStateRepository.setBreachEnabled(event.enabled) if (event.enabled) runHealthCheck(RunPhase.FirstLoad) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt index b3508b70c..7c567ba43 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/SegmentContainerColor.kt @@ -8,3 +8,6 @@ import androidx.compose.ui.graphics.Color internal val segmentContainerColor: Color @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainerHigh + +internal val openedSegmentContainerColor: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.primaryContainer diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt index c978a4566..81428e6fc 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt @@ -70,6 +70,7 @@ import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.openedSegmentContainerColor import de.davis.keygo.feature.password_health.presentation.segmentContainerColor @Composable @@ -77,6 +78,7 @@ internal fun ItemHealthRow( itemHealth: ItemHealth, shapes: ListItemShapes, pendingFix: FixFlow.Pending?, + isOpen: Boolean, onEvent: (PasswordHealthUiEvent) -> Unit, modifier: Modifier = Modifier, ) { @@ -85,9 +87,12 @@ internal fun ItemHealthRow( val username = itemHealth.username SegmentedListItem( + onClick = { onEvent(PasswordHealthUiEvent.ItemClicked(itemHealth.itemId)) }, shapes = shapes, modifier = modifier.fillMaxWidth(), - colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + colors = ListItemDefaults.segmentedColors( + containerColor = if (isOpen) openedSegmentContainerColor else segmentContainerColor, + ), supportingContent = if (pendingFix != null || username != null || weak != null) { { AnimatedContent( @@ -387,6 +392,7 @@ private fun ItemHealthRowPreview() { ), shapes = ListItemDefaults.shapes(MaterialTheme.shapes.large), pendingFix = null, + isOpen = false, onEvent = {}, ) @@ -403,6 +409,7 @@ private fun ItemHealthRowPreview() { itemId = itemId, password = "correct-horse-battery-staple", ), + isOpen = true, onEvent = {}, ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt index 9a198bb22..ebafe70ac 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.password_health.R @@ -36,6 +37,7 @@ import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthU internal fun LazyListScope.needsAttentionSection( sections: List, pendingFix: FixFlow.Pending?, + openItemId: ItemId?, onEvent: (PasswordHealthUiEvent) -> Unit, ) { item(key = "needs-attention-title", contentType = ContentType.Title) { @@ -66,16 +68,17 @@ internal fun LazyListScope.needsAttentionSection( GroupLabel(group = group, modifier = Modifier.animateItem()) } - segmentedRows(group.orderedMembers, pendingFix, onEvent) + segmentedRows(group.orderedMembers, pendingFix, openItemId, onEvent) } - segmentedRows(section.standalone, pendingFix, onEvent) + segmentedRows(section.standalone, pendingFix, openItemId, onEvent) } } private fun LazyListScope.segmentedRows( items: List, pendingFix: FixFlow.Pending?, + openItemId: ItemId?, onEvent: (PasswordHealthUiEvent) -> Unit, ) { itemsIndexed( @@ -87,6 +90,7 @@ private fun LazyListScope.segmentedRows( itemHealth = item, shapes = segmentedShapesFor(index = index, count = items.size), pendingFix = pendingFix?.takeIf { it.itemId == item.itemId }, + isOpen = item.itemId == openItemId, onEvent = onEvent, modifier = Modifier.animateItem(), ) @@ -154,6 +158,7 @@ private fun NeedsAttentionSectionPreview() { ) { needsAttentionSection( pendingFix = null, + openItemId = null, onEvent = {}, sections = listOf( HealthSection( diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt new file mode 100644 index 000000000..2ea1874a4 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthDetailPlaceholder.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.password_health.R + +@Composable +fun PasswordHealthDetailPlaceholder(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.password_health_detail_placeholder), + modifier = Modifier.padding(horizontal = 32.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +private fun PasswordHealthDetailPlaceholderPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PasswordHealthDetailPlaceholder() + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt new file mode 100644 index 000000000..1f82ee1af --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthEvent.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +internal sealed interface PasswordHealthEvent { + + data class OpenItem(val itemId: ItemId) : PasswordHealthEvent +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt index bee41bc22..9c235685c 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -6,6 +6,8 @@ internal sealed interface PasswordHealthUiEvent { data object RunHealthCheck : PasswordHealthUiEvent data object RefreshHealthCheck : PasswordHealthUiEvent + data class ItemClicked(val itemId: ItemId) : PasswordHealthUiEvent + data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent data class FixClicked(val itemId: ItemId) : PasswordHealthUiEvent diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index ad19b0f82..393316327 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -70,4 +70,6 @@ Cancel Your session ended before it could be saved. Try again. Couldn\'t save the new password. Try again. + + Pick an item to see it here From 081b77a0bcb4b45aff8123b5337208b842dae793 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Thu, 24 Sep 2026 22:56:25 +0200 Subject: [PATCH 17/54] refactor: rename package --- .../password_health/data/mapper/BreachCheckStateMapper.kt | 2 +- .../data/repository/BreachCheckStateRepositoryImpl.kt | 2 +- .../feature/password_health/di/FeaturePasswordHealthModule.kt | 2 +- feature/password-health/src/main/proto/breach_check_state.proto | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt index 51b632096..d51498a5a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt @@ -1,6 +1,6 @@ package de.davis.keygo.feature.password_health.data.mapper -import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState import de.davis.keygo.feature.password_health.domain.model.BreachCheckState internal fun ProtoBreachCheckState.toDomain() = BreachCheckState( diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt index 01c918506..71627a335 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.password_health.data.repository import androidx.datastore.core.DataStore -import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState import de.davis.keygo.feature.password_health.data.mapper.toDomain import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier import de.davis.keygo.feature.password_health.domain.model.BreachCheckState diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 0f15a88b8..f3e608bec 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -3,7 +3,7 @@ package de.davis.keygo.feature.password_health.di import android.content.Context import androidx.datastore.dataStore import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer -import de.davis.keygo.feature.backup.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier import okhttp3.Dispatcher import okhttp3.OkHttpClient diff --git a/feature/password-health/src/main/proto/breach_check_state.proto b/feature/password-health/src/main/proto/breach_check_state.proto index 26f1af48b..58a37fb45 100644 --- a/feature/password-health/src/main/proto/breach_check_state.proto +++ b/feature/password-health/src/main/proto/breach_check_state.proto @@ -1,6 +1,6 @@ syntax = "proto3"; -package de.davis.keygo.feature.backup.data.local.model; +package de.davis.keygo.feature.password_health.data.local.model; option java_multiple_files = true; message ProtoBreachCheckState { From 404daa893ebc3fdaded7f9fd298ba1fd8060b467 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 01:27:34 +0200 Subject: [PATCH 18/54] feat: add report store --- .../data/mapper/StoredHealthReportMapper.kt | 200 ++++++++++++++++++ .../di/FeaturePasswordHealthModule.kt | 15 ++ .../di/annotation/HealthReportStore.kt | 6 + .../domain/model/StoredHealthReport.kt | 31 +++ .../main/proto/password_health_report.proto | 66 ++++++ 5 files changed, 318 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt create mode 100644 feature/password-health/src/main/proto/password_health_report.proto diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt new file mode 100644 index 000000000..7591811bf --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt @@ -0,0 +1,200 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoCheckKind +import de.davis.keygo.feature.password_health.data.local.model.ProtoGapReason +import de.davis.keygo.feature.password_health.data.local.model.ProtoPasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.ProtoRelationType +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredItem +import de.davis.keygo.feature.password_health.data.local.model.protoCheckGap +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.protoStoredItem +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import java.util.zip.ZipInputStream +import kotlin.time.Instant +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +internal fun StoredHealthReport.toCompressedByteArray(): ByteArray = + ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(toProto().toByteArray()) } + }.toByteArray() + +internal fun ByteArray.toStoredHealthReport(): StoredHealthReport? = + ProtoStoredHealthReport.parseFrom(ZipInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) + .toDomain() + +private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { + if (algorithmVersion != StoredHealthReport.ALGORITHM_VERSION) return null + + val items = itemsList.map(ProtoStoredItem::toDomain) + + fun List.toItemIds(): Set = mapTo(HashSet(size)) { ref -> + items.getOrNull(ref)?.id + ?: throw IllegalStateException("Ref $ref out of range (${items.size} items)") + } + + val gaps = HashMap(gapsCount) + gapsList.forEach { gap -> + val kind = gap.kind.toDomain() + val reason = gap.reason.toDomain() + if (kind == null || reason == null) return@forEach + + val gapValue = CheckGap( + reason = reason, + unchecked = gap.uncheckedItemRefsList.toItemIds(), + ) + if (gaps.put(kind, gapValue) != null) { + throw IllegalStateException("Duplicate gap for $kind") + } + } + + return StoredHealthReport( + items = items, + relationalFindings = relationFindingList.mapNotNull { it.toDomain(it.itemRefsList.toItemIds()) }, + gaps = gaps, + generatedAt = Instant.fromEpochMilliseconds(generatedAtEpochMillis), + breachCheckEnabled = breachCheckEnabled, + ) +} + +private fun StoredHealthReport.toProto(): ProtoStoredHealthReport { + val refOf = HashMap(items.size) + items.forEachIndexed { index, item -> + check(refOf.put(item.id, index) == null) { "Duplicate item ${item.id}" } + } + fun Set.toRefs(): List = map { id -> + refOf[id] ?: error("Finding references $id, which is not in items") + } + + return protoStoredHealthReport { + items += this@toProto.items.map(StoredHealthItem::toProto) + relationFinding += this@toProto.relationalFindings.map { it.toProto(it.relatedItemIds.toRefs()) } + gaps += this@toProto.gaps.toProto { it.unchecked.toRefs() } + generatedAtEpochMillis = generatedAt.toEpochMilliseconds() + breachCheckEnabled = this@toProto.breachCheckEnabled + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + } +} + +private fun StoredHealthItem.toProto() = protoStoredItem { + id = this@toProto.id.toProtoByteString() + fingerprint = this@toProto.fingerprint.value.toByteString() + score = this@toProto.score.toProto() + + if (this@toProto.breachOccurrences > 0) + breachOccurrences = this@toProto.breachOccurrences + + unreadable = this@toProto.unreadable +} + +private fun ProtoStoredItem.toDomain(): StoredHealthItem = StoredHealthItem( + id = Uuid.fromByteArray(id.toByteArray()).toJavaUuid(), + fingerprint = HealthFingerprint(fingerprint.toByteArray()), + score = score.toDomain(), + breachOccurrences = breachOccurrences, + unreadable = unreadable, +) + +private fun HealthFinding.Relation.toProto(refs: List) = protoRelationFinding { + itemRefs += refs + type = this@toProto.type.toProto() +} + +private fun ProtoRelationFinding.toDomain(relatedItemIds: Set) = type.toDomain()?.let { + HealthFinding.Relation( + relatedItemIds = relatedItemIds, + type = it, + ) +} + +private fun Map.toProto(refs: (CheckGap) -> List) = + map { (checkKind, gap) -> + protoCheckGap { + kind = checkKind.toProto() + reason = gap.reason.toProto() + uncheckedItemRefs += refs(gap) + } + } + +private fun PasswordScore?.toProto() = when (this) { + null, + PasswordScore.None -> ProtoPasswordScore.PASSWORD_SCORE_NONE + + PasswordScore.Ridiculous -> ProtoPasswordScore.PASSWORD_SCORE_RIDICULOUS + PasswordScore.Weak -> ProtoPasswordScore.PASSWORD_SCORE_WEAK + PasswordScore.Moderate -> ProtoPasswordScore.PASSWORD_SCORE_MODERATE + PasswordScore.Strong -> ProtoPasswordScore.PASSWORD_SCORE_STRONG + PasswordScore.Excellent -> ProtoPasswordScore.PASSWORD_SCORE_EXCELLENT +} + +private fun ProtoPasswordScore.toDomain() = when (this) { + ProtoPasswordScore.UNRECOGNIZED, + ProtoPasswordScore.PASSWORD_SCORE_NONE -> null + + ProtoPasswordScore.PASSWORD_SCORE_RIDICULOUS -> PasswordScore.Ridiculous + ProtoPasswordScore.PASSWORD_SCORE_WEAK -> PasswordScore.Weak + ProtoPasswordScore.PASSWORD_SCORE_MODERATE -> PasswordScore.Moderate + ProtoPasswordScore.PASSWORD_SCORE_STRONG -> PasswordScore.Strong + ProtoPasswordScore.PASSWORD_SCORE_EXCELLENT -> PasswordScore.Excellent +} + +private fun RelationType.toProto() = when (this) { + RelationType.Reused -> ProtoRelationType.RELATION_TYPE_REUSED + RelationType.Similar -> ProtoRelationType.RELATION_TYPE_SIMILAR +} + +private fun ProtoRelationType.toDomain() = when (this) { + ProtoRelationType.UNRECOGNIZED -> null + + ProtoRelationType.RELATION_TYPE_REUSED -> RelationType.Reused + ProtoRelationType.RELATION_TYPE_SIMILAR -> RelationType.Similar +} + +private fun CheckKind.toProto() = when (this) { + CheckKind.Breach -> ProtoCheckKind.CHECK_KIND_BREACH + CheckKind.Reuse -> ProtoCheckKind.CHECK_KIND_REUSE + CheckKind.Strength -> ProtoCheckKind.CHECK_KIND_STRENGTH + CheckKind.Similarity -> ProtoCheckKind.CHECK_KIND_SIMILARITY +} + +private fun ProtoCheckKind.toDomain() = when (this) { + ProtoCheckKind.UNRECOGNIZED -> null + + ProtoCheckKind.CHECK_KIND_BREACH -> CheckKind.Breach + ProtoCheckKind.CHECK_KIND_REUSE -> CheckKind.Reuse + ProtoCheckKind.CHECK_KIND_STRENGTH -> CheckKind.Strength + ProtoCheckKind.CHECK_KIND_SIMILARITY -> CheckKind.Similarity +} + +private fun GapReason.toProto() = when (this) { + GapReason.Disabled -> ProtoGapReason.GAP_REASON_DISABLED + GapReason.Failed -> ProtoGapReason.GAP_REASON_FAILED + GapReason.Unreachable -> ProtoGapReason.GAP_REASON_UNREACHABLE +} + +private fun ProtoGapReason.toDomain() = when (this) { + ProtoGapReason.UNRECOGNIZED -> null + + ProtoGapReason.GAP_REASON_DISABLED -> GapReason.Disabled + ProtoGapReason.GAP_REASON_FAILED -> GapReason.Failed + ProtoGapReason.GAP_REASON_UNREACHABLE -> GapReason.Unreachable +} + +private fun ItemId.toProtoByteString() = toKotlinUuid().toByteArray().toByteString() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index f3e608bec..84f64f81e 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.datastore.dataStore import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore import okhttp3.Dispatcher import okhttp3.OkHttpClient import org.koin.core.annotation.ComponentScan @@ -46,11 +48,24 @@ object FeaturePasswordHealthModule { ) ) + private val Context.healthReportStoreDataStore by dataStore( + "password_health_report_store.pb", + DefaultProtoSerializer( + defaultInstance = ProtoHealthReportStore.getDefaultInstance(), + parser = ProtoHealthReportStore.parser() + ) + ) + @Single @BreachedQualifier internal fun provideBreachCheckStateDataStore(context: Context) = context.breachCheckStateDataStore + @Single + @HealthReportStore + internal fun provideHealthReportStoreDataStore(context: Context) = + context.healthReportStoreDataStore + private fun Context.versionName(): String = packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt new file mode 100644 index 000000000..0ed3dc52e --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthReportStore.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthReportStore diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt new file mode 100644 index 000000000..71033009a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import kotlin.time.Instant + +data class StoredHealthReport( + val items: List, + val relationalFindings: List, + val gaps: Map = emptyMap(), + val generatedAt: Instant, + val breachCheckEnabled: Boolean, +) { + + val totalPasswordsScanned = items.size + + companion object { + const val ALGORITHM_VERSION = 1 + } +} + +data class StoredHealthItem( + val id: ItemId, + val fingerprint: HealthFingerprint, + val score: PasswordScore?, + val breachOccurrences: Int, + val unreadable: Boolean, +) + +@JvmInline +value class HealthFingerprint(val value: ByteArray) diff --git a/feature/password-health/src/main/proto/password_health_report.proto b/feature/password-health/src/main/proto/password_health_report.proto new file mode 100644 index 000000000..e4a3094cc --- /dev/null +++ b/feature/password-health/src/main/proto/password_health_report.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.password_health.data.local.model; +option java_multiple_files = true; + +message ProtoHealthReportStore { + bytes wrapped_key = 1; + bytes wrapped_key_nonce = 2; + bytes ciphertext = 3; + bytes iv = 4; +} + +message ProtoStoredHealthReport { + repeated ProtoStoredItem items = 1; + repeated ProtoRelationFinding relation_finding = 2; + repeated ProtoCheckGap gaps = 3; + int64 generated_at_epoch_millis = 4; + int32 algorithm_version = 5; + bool breach_check_enabled = 6; +} + +message ProtoStoredItem { + bytes id = 1; // 16 raw bytes of the UUID + bytes fingerprint = 2; // raw digest, not hex/base64 + optional int32 breach_occurrences = 3; // unset = never looked up + ProtoPasswordScore score = 4; + bool unreadable = 5; +} + +message ProtoRelationFinding { + repeated uint32 item_refs = 1; + ProtoRelationType type = 2; +} + +message ProtoCheckGap { + ProtoCheckKind kind = 1; + ProtoGapReason reason = 2; + repeated uint32 unchecked_item_refs = 3; +} + +enum ProtoPasswordScore { + PASSWORD_SCORE_NONE = 0; + PASSWORD_SCORE_RIDICULOUS = 1; + PASSWORD_SCORE_WEAK = 2; + PASSWORD_SCORE_MODERATE = 3; + PASSWORD_SCORE_STRONG = 4; + PASSWORD_SCORE_EXCELLENT = 5; +} + +enum ProtoRelationType { + RELATION_TYPE_REUSED = 0; + RELATION_TYPE_SIMILAR = 1; +} + +enum ProtoCheckKind { + CHECK_KIND_STRENGTH = 0; + CHECK_KIND_REUSE = 1; + CHECK_KIND_BREACH = 2; + CHECK_KIND_SIMILARITY = 3; +} + +enum ProtoGapReason { + GAP_REASON_DISABLED = 0; + GAP_REASON_UNREACHABLE = 1; + GAP_REASON_FAILED = 2; +} From 608f070471e7d8ac7a7557a9a2e568b173c59d78 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 11:50:24 +0200 Subject: [PATCH 19/54] feat: add account scope --- .../CryptographicScopeProviderFactoryImpl.kt | 10 ++- .../crypto/CryptographicScopeProviderImpl.kt | 29 +++++++++ .../crypto/CryptographicScopeProvider.kt | 7 +++ .../BindingCryptographicScopeProvider.kt | 3 + .../crypto/FakeCryptographicScopeProvider.kt | 61 ++++++++++++------- .../usecase/MoveItemsToVaultUseCaseTest.kt | 10 ++- 6 files changed, 96 insertions(+), 24 deletions(-) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt index 20f5fb7a6..0bb965df0 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt @@ -5,6 +5,7 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import org.koin.core.annotation.Single @@ -13,8 +14,15 @@ internal class CryptographicScopeProviderFactoryImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, + private val vaultManager: VaultManager, ) : CryptographicScopeProviderFactory { override fun forSession(session: Session): CryptographicScopeProvider = - CryptographicScopeProviderImpl(session, itemRepository, itemManager, keyWrapper) + CryptographicScopeProviderImpl( + session, + itemRepository, + itemManager, + keyWrapper, + vaultManager + ) } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt index 1f716f436..8792d7e61 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt @@ -15,6 +15,7 @@ import de.davis.keygo.core.util.mapFailure import de.davis.keygo.core.util.mapSuccess import de.davis.keygo.core.util.resultBinding import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import de.davis.keygo.rust.wrap.unwrapItemKeyWithResult import de.davis.keygo.rust.wrap.wrapItemKeyWithResult @@ -22,6 +23,7 @@ import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single +import java.util.UUID @Single internal class CryptographicScopeProviderImpl( @@ -29,6 +31,7 @@ internal class CryptographicScopeProviderImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, + private val vaultManager: VaultManager, ) : CryptographicScopeProvider { override suspend fun itemScope( @@ -84,6 +87,32 @@ internal class CryptographicScopeProviderImpl( ).block() } + override suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R + ): Result = resultBinding { + val (key, wrappedKey) = if (wrapped != null) { + val unwrapped = session.unwrapVaultKey( + wrapped = wrapped.toWrappedKeyBlob(), + vaultId = namespace, + ).mapFailure { it.toCryptoScopeError() }.bind() + unwrapped to wrapped + } else { + val fresh = vaultManager.createNewVaultKey() + val blob = session.wrapVaultKey(vaultKey = fresh, vaultId = namespace) + .mapFailure { it.toCryptoScopeError() }.bind() + fresh to blob.toKeyInformation() + } + + CryptographicScopeImpl( + itemKey = key, + itemAad = ItemAad(itemId = namespace, vaultId = namespace), + itemManager = itemManager, + wrapAction = { wrappedKey }, + ).block() + } + override suspend fun rewrapItemKey( sourceVault: WrappedVaultKeyInformation, sourceItem: WrappedItemKeyInformation, diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt index f946598b3..6fd3d4d99 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result +import java.util.UUID interface CryptographicScopeProvider { @@ -20,6 +21,12 @@ interface CryptographicScopeProvider { block: suspend CryptographicScope.() -> R, ): Result + suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R, + ): Result + /** * Re-wraps an item's key from one vault to another without exposing the unwrapped item key * or touching the item's encrypted secrets. The destination AAD reuses the item id but is diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt index 3195cb1d9..d2429bbe5 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt @@ -6,6 +6,7 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davisalessandro.keygo.rust.ItemManagerInterface import de.davisalessandro.keygo.rust.KeyWrapperInterface +import de.davisalessandro.keygo.rust.VaultManagerInterface /** * Constructs the production [CryptographicScopeProvider] backed by the supplied fakes. @@ -21,9 +22,11 @@ fun BindingCryptographicScopeProvider( itemRepository: ItemRepository, itemManager: ItemManagerInterface, keyWrapper: KeyWrapperInterface, + vaultManager: VaultManagerInterface, ): CryptographicScopeProvider = CryptographicScopeProviderImpl( session = session, itemRepository = itemRepository, itemManager = itemManager, keyWrapper = keyWrapper, + vaultManager = vaultManager, ) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt index 4df7fa738..8bb556fc8 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformati import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import java.util.Collections +import java.util.UUID import kotlin.coroutines.CoroutineContext import kotlin.experimental.xor @@ -27,6 +28,8 @@ class FakeCryptographicScopeProvider( val sourceItem: WrappedItemKeyInformation, val destinationVault: WrappedVaultKeyInformation, ) : CallHistory + + class AccountScopeCall(val namespace: UUID, val hadKey: Boolean) : CallHistory } // Synchronized: BackupCollector calls encrypt/decrypt concurrently across items, and this @@ -36,6 +39,9 @@ class FakeCryptographicScopeProvider( get() = callHistory.filterIsInstance() val rewrapCalls get() = callHistory.filterIsInstance() + val accountScopeCalls + get() = callHistory.filterIsInstance() + /** Result returned by the next [rewrapItemKey] call. */ var rewrapResult: Result = @@ -72,28 +78,18 @@ class FakeCryptographicScopeProvider( ): Result { itemScopeFailure?.let { return Result.Failure(it) } - return block( - object : CryptographicScope { - override suspend fun ByteArray.encrypt( - label: String, - context: CoroutineContext, - ): CryptographicData { - callHistory += CallHistory.EncryptCall(label, this.copyOf()) - return CryptographicData(data = transform(this), iv = IV) - } - - override suspend fun CryptographicData.decrypt( - label: String, - context: CoroutineContext, - ): ByteArray { - callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) - return transform(data) - } - - override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = - KeyInformation(byteArrayOf(), byteArrayOf()) - }, - ).let(Result::Success) + return block(recordingScope()).let(Result::Success) + } + + override suspend fun accountScope( + namespace: UUID, + wrapped: KeyInformation?, + block: suspend CryptographicScope.() -> R + ): Result { + callHistory += CallHistory.AccountScopeCall(namespace, wrapped != null) + itemScopeFailure?.let { return Result.Failure(it) } + + return block(recordingScope()).let(Result::Success) } override suspend fun rewrapItemKey( @@ -105,6 +101,27 @@ class FakeCryptographicScopeProvider( return rewrapResult } + private fun recordingScope() = object : CryptographicScope { + override suspend fun ByteArray.encrypt( + label: String, + context: CoroutineContext, + ): CryptographicData { + callHistory += CallHistory.EncryptCall(label, this.copyOf()) + return CryptographicData(data = transform(this), iv = IV) + } + + override suspend fun CryptographicData.decrypt( + label: String, + context: CoroutineContext, + ): ByteArray { + callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) + return transform(data) + } + + override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = + KeyInformation(byteArrayOf(), byteArrayOf()) + } + companion object { val IV: ByteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) private val KEY: ByteArray = byteArrayOf(0xCA.toByte(), 0xFE.toByte()) diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index 2c491a34d..0bb19296c 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -34,6 +34,7 @@ import de.davis.keygo.feature.vault.domain.model.MoveItemsError import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.runBlocking @@ -54,8 +55,15 @@ class MoveItemsToVaultUseCaseTest { private val itemRepository = FakeItemRepository(loginRepository) private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() + private val vaultManager = FakeVaultManager() private val cryptographicScopeProvider: CryptographicScopeProvider = - BindingCryptographicScopeProvider(session, itemRepository, itemManager, keyWrapper) + BindingCryptographicScopeProvider( + session, + itemRepository, + itemManager, + keyWrapper, + vaultManager + ) private val vaultRepository = FakeVaultRepository() From c2298d73f612ebb30ad46075615dc05323aa1d45 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 12:19:48 +0200 Subject: [PATCH 20/54] feat: add health report store repository --- .../HealthReportStoreRepositoryImpl.kt | 86 +++++++++++++++++++ .../domain/model/HealthReportStoreError.kt | 7 ++ .../repository/HealthReportStoreRepository.kt | 11 +++ 3 files changed, 104 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt new file mode 100644 index 000000000..dfdcaa1cc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import com.google.protobuf.kotlin.isNotEmpty +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.mapper.toCompressedByteArray +import de.davis.keygo.feature.password_health.data.mapper.toStoredHealthReport +import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import java.util.UUID + +@Single +internal class HealthReportStoreRepositoryImpl( + @HealthReportStore + private val dataStore: DataStore, + private val cryptographicScopeProvider: CryptographicScopeProvider, +) : HealthReportStoreRepository { + + override suspend fun storeReport(report: StoredHealthReport): Result = + resultBinding { + val wrappedKey = dataStore.data.first() + .takeIf { it.wrappedKey.isNotEmpty() } + + val compressedReport = report.toCompressedByteArray() + + val (sealedReport, key) = cryptographicScopeProvider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = wrappedKey?.toWrappedKey(), + ) { + compressedReport.encrypt(REPORT_LABEL) to wrapCurrentItemKey() + }.bind { HealthReportStoreError.CryptoFailed } + + dataStore.updateData { + it.toBuilder() + .setWrappedKey(key.wrappedKey.toByteString()) + .setWrappedKeyNonce(key.keyNonce.toByteString()) + .setCiphertext(sealedReport.data.toByteString()) + .setIv(sealedReport.iv.toByteString()) + .build() + } + } + + override suspend fun load(): Result = + resultBinding { + val stored = dataStore.data.first() + if (stored.wrappedKey.isEmpty || stored.ciphertext.isEmpty) { + return Result.Failure(HealthReportStoreError.NoReportStored) + } + + val compressedReport = cryptographicScopeProvider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = stored.toWrappedKey(), + ) { + CryptographicData( + data = stored.ciphertext.toByteArray(), + iv = stored.iv.toByteArray(), + ).decrypt(REPORT_LABEL) + }.bind { HealthReportStoreError.CryptoFailed } + + compressedReport.toStoredHealthReport() + .asResult(HealthReportStoreError.CorruptedReport) + .bind() + } + + private fun ProtoHealthReportStore.toWrappedKey() = KeyInformation( + wrappedKey = wrappedKey.toByteArray(), + keyNonce = wrappedKeyNonce.toByteArray(), + ) + + companion object { + private val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") + + private const val REPORT_LABEL = "password_health_report" + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt new file mode 100644 index 000000000..f27e4599a --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthReportStoreError.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface HealthReportStoreError { + object NoReportStored : HealthReportStoreError + object CryptoFailed : HealthReportStoreError + object CorruptedReport : HealthReportStoreError +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt new file mode 100644 index 000000000..4aba0b2e6 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthReportStoreRepository.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport + +interface HealthReportStoreRepository { + + suspend fun storeReport(report: StoredHealthReport): Result + suspend fun load(): Result +} From b1352876511ca406efba3873eda305c52d0d2667 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 12:38:09 +0200 Subject: [PATCH 21/54] feat: add fingerprinter --- .../data/LoginFingerprinterImpl.kt | 43 +++++++++++++++++++ .../domain/LoginFingerprinter.kt | 14 ++++++ 2 files changed, 57 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt new file mode 100644 index 000000000..81632d4dc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImpl.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.feature.password_health.data + +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.coroutines.CoroutineContext +import kotlin.uuid.toKotlinUuid + +@Single +internal class LoginFingerprinterImpl : LoginFingerprinter { + + override suspend fun fingerprint( + login: Login, + coroutineContext: CoroutineContext + ): HealthFingerprint? = withContext(coroutineContext) { + val payload = login.passwordCredential?.secret?.payload ?: return@withContext null + + val digest = MessageDigest.getInstance("SHA-256") + + digest.update(login.id.toKotlinUuid().toByteArray()) + digest.updateWithPrefix(payload.ciphertext) + digest.updateWithPrefix(payload.iv) + + HealthFingerprint(digest.digest()) + } + + private fun MessageDigest.updateWithPrefix(data: ByteArray) { + update( + ByteBuffer.allocate(LENGTH_PREFIX_BYTES) + .putInt(data.size) + .array(), + ) + update(data) + } + + companion object { + private const val LENGTH_PREFIX_BYTES = 4 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt new file mode 100644 index 000000000..413a464fe --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/LoginFingerprinter.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +interface LoginFingerprinter { + + suspend fun fingerprint( + login: Login, + coroutineContext: CoroutineContext = Dispatchers.Default + ): HealthFingerprint? +} From f176595412fbfdda6a53f350f8ab4ed242c6cc95 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 18:29:27 +0200 Subject: [PATCH 22/54] fix: health store compression --- .../password_health/data/mapper/StoredHealthReportMapper.kt | 4 ++-- .../data/repository/HealthReportStoreRepositoryImpl.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt index 7591811bf..24a4d4b9b 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt @@ -24,8 +24,8 @@ import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream -import java.util.zip.ZipInputStream import kotlin.time.Instant import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid @@ -37,7 +37,7 @@ internal fun StoredHealthReport.toCompressedByteArray(): ByteArray = }.toByteArray() internal fun ByteArray.toStoredHealthReport(): StoredHealthReport? = - ProtoStoredHealthReport.parseFrom(ZipInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) + ProtoStoredHealthReport.parseFrom(GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) .toDomain() private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt index dfdcaa1cc..9ae303475 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -29,14 +29,14 @@ internal class HealthReportStoreRepositoryImpl( override suspend fun storeReport(report: StoredHealthReport): Result = resultBinding { - val wrappedKey = dataStore.data.first() + val stored = dataStore.data.first() .takeIf { it.wrappedKey.isNotEmpty() } val compressedReport = report.toCompressedByteArray() val (sealedReport, key) = cryptographicScopeProvider.accountScope( namespace = REPORT_NAMESPACE, - wrapped = wrappedKey?.toWrappedKey(), + wrapped = stored?.toWrappedKey(), ) { compressedReport.encrypt(REPORT_LABEL) to wrapCurrentItemKey() }.bind { HealthReportStoreError.CryptoFailed } From 8b21197a9330193b3102270416ac827b483b467e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 18:59:22 +0200 Subject: [PATCH 23/54] feat: cache report --- .../domain/model/PasswordCandidate.kt | 3 - .../domain/model/PasswordHealthReportError.kt | 1 + .../domain/model/StoredHealthReport.kt | 17 +- .../usecase/PasswordHealthReportUseCase.kt | 266 ++++++++++++------ .../model/PasswordHealthUiState.kt | 1 + 5 files changed, 204 insertions(+), 84 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt index 5c863f1d2..214ef2db5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordCandidate.kt @@ -5,9 +5,6 @@ import de.davis.keygo.core.item.domain.model.PasswordScore class PasswordCandidate( val id: ItemId, - val title: String, - val username: String?, val score: PasswordScore, val password: CharArray, - val urls: List, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt index f476f9582..3e1959528 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt @@ -3,4 +3,5 @@ package de.davis.keygo.feature.password_health.domain.model sealed interface PasswordHealthReportError { data object NoPasswords : PasswordHealthReportError data object Unreadable : PasswordHealthReportError + data object StoreFailed : PasswordHealthReportError } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt index 71033009a..1b137484f 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -27,5 +27,18 @@ data class StoredHealthItem( val unreadable: Boolean, ) -@JvmInline -value class HealthFingerprint(val value: ByteArray) +class HealthFingerprint(val value: ByteArray) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HealthFingerprint + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return value.contentHashCode() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 39bf70f1a..df1f43b18 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.password_health.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Login -import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt @@ -10,123 +9,232 @@ import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull -import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.firstOrNull import org.koin.core.annotation.Single +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days @Single class PasswordHealthReportUseCase( private val loginRepository: LoginRepository, private val cryptographicScopeProvider: CryptographicScopeProvider, + private val loginFingerprinter: LoginFingerprinter, + private val healthReportStoreRepository: HealthReportStoreRepository, + private val breachCheckStateRepository: BreachCheckStateRepository, private val checker: List, ) { suspend operator fun invoke(): Result = resultBinding { - val withPassword = loginRepository.observeLogins() - .firstOrNull() - .orEmpty() - .mapNotNull { login -> login.passwordCredential?.let { login to it } } - - withPassword.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() - - // A password that will not decrypt is checked by nobody, so it is reported instead - // of quietly dropping out of the count. - val unreadable = mutableSetOf() - val candidates = withPassword.mapNotNull { (login, credential) -> - candidate(login, credential) - .onFailure { unreadable += login.id } - .getOrNull() - } + coroutineScope { + val logins = loginRepository.observeLogins() + .firstOrNull() + ?.filter { it.passwordCredential != null } + .asResult(PasswordHealthReportError.NoPasswords) + .bind() + + val fingerprints = logins.map { login -> + async { loginFingerprinter.fingerprint(login)?.let { login.id to it } } + } + .awaitAll() + .filterNotNull() + .toMap() - candidates.isNotEmpty().asResult(PasswordHealthReportError.Unreadable).bind() + val breachCheckEnabled = breachCheckStateRepository.getBreachCheckState().enabled + val storedReport = healthReportStoreRepository.load().getOrNull() - val outcomes = try { - coroutineScope { - checker.map { async { it.type to it.check(candidates) } }.awaitAll() + if (storedReport != null + && storedReport.isUpToDate(fingerprints, breachCheckEnabled) + ) { + return@coroutineScope storedReport.assembleReport(logins) } - } finally { - candidates.forEach { it.password.fill('\u0000') } + + val report = logins.computeReport(fingerprints, breachCheckEnabled).bind() + return@coroutineScope report.assembleReport(logins) } + } + + private fun StoredHealthReport.isUpToDate( + fingerprints: Map, + breachCheckEnabled: Boolean + ): Boolean { + val fingerprintsMatch = items.associate { it.id to it.fingerprint } == fingerprints + val expired = Clock.System.now() - generatedAt >= REPORT_TTL - val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } - val gaps = outcomes.mapNotNull { (kind, outcome) -> - outcome.gap?.let { kind to it } - }.toMap() + return fingerprintsMatch && !expired && this.breachCheckEnabled == breachCheckEnabled + } + + private suspend fun Collection.computeReport( + fingerprints: Map, + breachCheckEnabled: Boolean, + ) = resultBinding { + val results = coroutineScope { + map { login -> async { login.id to candidate(login) } }.awaitAll() + } - val issuesById = findings.filterIsInstance() - .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) - val relations = findings.filterIsInstance() + // A password that will not decrypt is checked by nobody, so it is reported instead + // of quietly dropping out of the count. + val unreadable = results + .filter { (_, result) -> result?.isFailure() == true } + .mapTo(mutableSetOf()) { (id, _) -> id } - // Connected components over all relation edges - val uf = UnionFind() - relations.forEach { r -> - val first = r.relatedItemIds.first() - r.relatedItemIds.forEach { uf.union(first, it) } + val candidates = results.mapNotNull { (_, result) -> result?.getOrNull() } + candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + + val outcomes = try { + coroutineScope { + checker.map { async { it.type to it.check(candidates) } }.awaitAll() } + } finally { + candidates.forEach { it.password.fill('\u0000') } + } + + val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } + + val relations = findings.filterIsInstance() + val issuesById = findings.filterIsInstance() + .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) + + val report = StoredHealthReport( + items = mapToStoredHealthItems(issuesById, fingerprints, unreadable), + relationalFindings = relations, + gaps = emptyMap(), + generatedAt = Clock.System.now(), + breachCheckEnabled = breachCheckEnabled + ) + + healthReportStoreRepository.storeReport(report) + .bind { PasswordHealthReportError.StoreFailed } + report + } + + private fun StoredHealthReport.assembleReport(logins: List): PasswordHealthReport { + val loginsById = logins.associateBy { it.id } + + val issuesById = items.filterFindings() + .filter { it.id in loginsById } + .associate { it.id to it.issues() } + + val relations = relationalFindings.mapNotNull { + val surviving = it.relatedItemIds.filterTo(mutableSetOf()) { it in loginsById } + if (surviving.size > 1) it.copy(relatedItemIds = surviving) else null + } + + val uf = UnionFind() + relations.forEach { relation -> + val first = relation.relatedItemIds.first() + relation.relatedItemIds.forEach { uf.union(first, it) } + } - fun health(item: PasswordCandidate) = ItemHealth( + fun health(item: StoredHealthItem): ItemHealth { + val login = loginsById.getValue(item.id) + return ItemHealth( itemId = item.id, - title = item.title, - username = item.username, + title = login.name, + username = login.username, issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, - urls = item.urls, + urls = login.domainInfos.map { it.value }, ) + } - val (related, unrelated) = candidates.partition { uf.contains(it.id) } - val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } - - val group = related.groupBy { uf.find(it.id) } - .map { (root, members) -> - RelatedGroup( - id = root, - members = members.mapTo( - mutableSetOf(), - ::health - ), - relations = relationsById.getValue(root).toSet() - ) - } - .sortedByDescending { it.maxSeverity } - - val standalone = unrelated.map(::health) - .filter { it.issues.isNotEmpty() } - .sortedByDescending { it.maxSeverity } - - PasswordHealthReport( - groups = group, - standalone = standalone, - totalPasswordsScanned = candidates.size, - gaps = gaps, - unreadable = unreadable, + val (related, unrelated) = items.partition { uf.contains(it.id) } + val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } + + val groups = related.groupBy { uf.find(it.id) } + .map { (root, members) -> + RelatedGroup( + id = root, + members = members.mapTo(mutableSetOf(), ::health), + relations = relationsById.getValue(root).toSet(), + ) + } + .sortedByDescending { it.maxSeverity } + + val standalone = unrelated.map(::health) + .filter { it.issues.isNotEmpty() } + .sortedByDescending { it.maxSeverity } + + val survivingGaps = gaps.mapNotNull { (kind, gap) -> + val unchecked = gap.unchecked.filterTo(mutableSetOf()) { it in loginsById } + if (unchecked.isNotEmpty()) kind to gap.copy(unchecked = unchecked) else null + }.toMap() + + return PasswordHealthReport( + groups = groups, + standalone = standalone, + totalPasswordsScanned = totalPasswordsScanned, + gaps = survivingGaps, + unreadable = items.filter { it.unreadable } + .mapNotNullTo(mutableSetOf()) { it.id.takeIf { id -> id in loginsById } } + ) + } + + private fun Collection.filterFindings() = filter { + (it.score != null && !it.score.isNone) || + it.breachOccurrences > 0 + } + + private fun StoredHealthItem.issues() = buildList { + score?.takeIf { !it.isNone }?.let { add(ItemIssue.Weak(it)) } + if (breachOccurrences > 0) add(ItemIssue.Breached(breachOccurrences)) + } + + private fun Collection.mapToStoredHealthItems( + issuesById: Map>, + fingerprints: Map, + unreadable: Set, + ): List = mapNotNull { item -> + val issues = issuesById[item.id].orEmpty() + val score = issues.filterIsInstance().firstOrNull()?.score + val breachOccurrences = issues.filterIsInstance().firstOrNull() + ?.occurrences + ?: 0 + + fingerprints[item.id]?.let { fingerprint -> + StoredHealthItem( + id = item.id, + fingerprint = fingerprint, + score = score, + breachOccurrences = breachOccurrences, + unreadable = item.id in unreadable ) } + } - private suspend fun candidate( - login: Login, - credential: PasswordCredential, - ): Result = cryptographicScopeProvider.itemScope( - itemId = login.id, - ) { - PasswordCandidate( - id = login.id, - title = login.name, - username = login.username, - score = credential.score, - password = credential.secret.decrypt().toCharArray(), - urls = login.domainInfos.map { it.value }, - ) + private suspend fun candidate(login: Login): Result? = + login.passwordCredential?.let { credential -> + cryptographicScopeProvider.itemScope( + itemId = login.id, + ) { + PasswordCandidate( + id = login.id, + score = credential.score, + password = credential.secret.decrypt().toCharArray(), + ) + } + } + + companion object { + private val REPORT_TTL = 1.days } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index aed48c631..5073a97b0 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -223,6 +223,7 @@ internal fun PasswordHealthUiState.coverageNote(): String? { @ReadOnlyComposable private fun PasswordHealthReportError?.failureMessage(): String = when (this) { PasswordHealthReportError.Unreadable -> stringResource(R.string.password_health_unreadable) + PasswordHealthReportError.StoreFailed, PasswordHealthReportError.NoPasswords, null -> stringResource(R.string.password_health_check_failed) } From d4105f00a37b3b093d0f3ef1d75cc6dd128357ca Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 19:34:59 +0200 Subject: [PATCH 24/54] test: drop removed PasswordCandidate fields from fixtures --- .../feature/password_health/domain/checker/CheckerFixtures.kt | 3 --- .../feature/password_health/domain/checker/SimilarityCorpus.kt | 3 --- 2 files changed, 6 deletions(-) diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt index f75807f6c..8e5f3830f 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/CheckerFixtures.kt @@ -10,11 +10,8 @@ internal fun candidates(passwords: List): List = passwords.mapIndexed { index, password -> PasswordCandidate( id = UUID(0L, index.toLong()), - title = "item $index", - username = null, score = PasswordScore.Moderate, password = password.toCharArray(), - urls = emptyList(), ) } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt index 545a6d4cd..057310b5a 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/SimilarityCorpus.kt @@ -139,11 +139,8 @@ internal class Corpus(val entries: List) { fun candidates(): List = entries.map { PasswordCandidate( id = it.id, - title = it.archetype, - username = null, score = PasswordScore.Moderate, password = it.password.toCharArray(), - urls = emptyList(), ) } } From fe6335b237846418e6d05e183b2909b6a764579e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 19:34:59 +0200 Subject: [PATCH 25/54] fix: keep checker gaps in the health report --- .../domain/usecase/PasswordHealthReportUseCase.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index df1f43b18..76439d109 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -109,6 +109,8 @@ class PasswordHealthReportUseCase( } val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } + val gaps = outcomes.mapNotNull { (type, outcome) -> outcome.gap?.let { type to it } } + .toMap() val relations = findings.filterIsInstance() val issuesById = findings.filterIsInstance() @@ -117,7 +119,7 @@ class PasswordHealthReportUseCase( val report = StoredHealthReport( items = mapToStoredHealthItems(issuesById, fingerprints, unreadable), relationalFindings = relations, - gaps = emptyMap(), + gaps = gaps, generatedAt = Clock.System.now(), breachCheckEnabled = breachCheckEnabled ) From 3c1e4b7e748486d53a78890026a21a78a81117ac Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 19:42:14 +0200 Subject: [PATCH 26/54] refactor: split health report scanning and assembly out of the use case --- feature/password-health/build.gradle.kts | 3 + .../domain/model/StoredHealthReport.kt | 10 + .../domain/report/HealthReportAssembler.kt | 87 ++++++ .../domain/report/HealthReportScanner.kt | 111 ++++++++ .../domain/report/UnionFind.kt | 17 ++ .../usecase/PasswordHealthReportUseCase.kt | 254 +++--------------- .../domain/model/StoredHealthReportTest.kt | 61 +++++ .../report/HealthReportAssemblerTest.kt | 129 +++++++++ .../domain/report/HealthReportScannerTest.kt | 150 +++++++++++ .../domain/report/ReportFixtures.kt | 63 +++++ 10 files changed, 664 insertions(+), 221 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 9fd8732fb..2210f6032 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -25,4 +25,7 @@ dependencies { implementation(libs.gosimple.nbvcxz) implementation(libs.androidx.datastore) + + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt index 1b137484f..c062d4cde 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.password_health.domain.model import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore +import kotlin.time.Duration.Companion.days import kotlin.time.Instant data class StoredHealthReport( @@ -14,8 +15,17 @@ data class StoredHealthReport( val totalPasswordsScanned = items.size + fun isFreshFor( + fingerprints: Map, + breachCheckEnabled: Boolean, + now: Instant, + ): Boolean = this.breachCheckEnabled == breachCheckEnabled + && now - generatedAt < TTL + && items.associate { it.id to it.fingerprint } == fingerprints + companion object { const val ALGORITHM_VERSION = 1 + val TTL = 1.days } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt new file mode 100644 index 000000000..bf9d65df4 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import org.koin.core.annotation.Single + +@Single +class HealthReportAssembler { + + fun assemble(report: StoredHealthReport, logins: List): PasswordHealthReport { + val loginsById = logins.associateBy { it.id } + + val issuesById = report.items.filterFindings() + .filter { it.id in loginsById } + .associate { it.id to it.issues() } + + val relations = report.relationalFindings.mapNotNull { + val surviving = it.relatedItemIds.filterTo(mutableSetOf()) { it in loginsById } + if (surviving.size > 1) it.copy(relatedItemIds = surviving) else null + } + + val uf = UnionFind() + relations.forEach { relation -> + val first = relation.relatedItemIds.first() + relation.relatedItemIds.forEach { uf.union(first, it) } + } + + fun health(item: StoredHealthItem): ItemHealth { + val login = loginsById.getValue(item.id) + return ItemHealth( + itemId = item.id, + title = login.name, + username = login.username, + issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, + urls = login.domainInfos.map { it.value }, + ) + } + + val (related, unrelated) = report.items.partition { uf.contains(it.id) } + val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } + + val groups = related.groupBy { uf.find(it.id) } + .map { (root, members) -> + RelatedGroup( + id = root, + members = members.mapTo(mutableSetOf(), ::health), + relations = relationsById.getValue(root).toSet(), + ) + } + .sortedByDescending { it.maxSeverity } + + val standalone = unrelated.filter { it.id in loginsById } + .map(::health) + .filter { it.issues.isNotEmpty() } + .sortedByDescending { it.maxSeverity } + + val survivingGaps = report.gaps.mapNotNull { (kind, gap) -> + val unchecked = gap.unchecked.filterTo(mutableSetOf()) { it in loginsById } + if (unchecked.isNotEmpty()) kind to gap.copy(unchecked = unchecked) else null + }.toMap() + + return PasswordHealthReport( + groups = groups, + standalone = standalone, + totalPasswordsScanned = report.totalPasswordsScanned, + gaps = survivingGaps, + unreadable = report.items.filter { it.unreadable } + .mapNotNullTo(mutableSetOf()) { it.id.takeIf { id -> id in loginsById } }, + ) + } + + private fun Collection.filterFindings() = filter { + (it.score != null && !it.score.isNone) || + it.breachOccurrences > 0 + } + + private fun StoredHealthItem.issues() = buildList { + score?.takeIf { !it.isNone }?.let { add(ItemIssue.Weak(it)) } + if (breachOccurrences > 0) add(ItemIssue.Breached(breachOccurrences)) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt new file mode 100644 index 000000000..3a8f99018 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt @@ -0,0 +1,111 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koin.core.annotation.Single +import kotlin.time.Instant + +@Single +class HealthReportScanner( + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val checkers: List, +) { + + suspend fun scan( + logins: Collection, + fingerprints: Map, + breachCheckEnabled: Boolean, + generatedAt: Instant, + ): Result = resultBinding { + val results = coroutineScope { + logins.map { login -> async { login.id to candidate(login) } }.awaitAll() + } + + // A password that will not decrypt is checked by nobody, so it is reported instead + // of quietly dropping out of the count. + val unreadable = results + .filter { (_, result) -> result?.isFailure() == true } + .mapTo(mutableSetOf()) { (id, _) -> id } + + val candidates = results.mapNotNull { (_, result) -> result?.getOrNull() } + candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + + val outcomes = try { + coroutineScope { + checkers.map { async { it.type to it.check(candidates) } }.awaitAll() + } + } finally { + candidates.forEach { it.password.fill('\u0000') } + } + + val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } + val gaps = outcomes.mapNotNull { (type, outcome) -> outcome.gap?.let { type to it } } + .toMap() + + val relations = findings.filterIsInstance() + val issuesById = findings.filterIsInstance() + .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) + + StoredHealthReport( + items = logins.mapToStoredHealthItems(issuesById, fingerprints, unreadable), + relationalFindings = relations, + gaps = gaps, + generatedAt = generatedAt, + breachCheckEnabled = breachCheckEnabled, + ) + } + + private fun Collection.mapToStoredHealthItems( + issuesById: Map>, + fingerprints: Map, + unreadable: Set, + ): List = mapNotNull { item -> + val issues = issuesById[item.id].orEmpty() + val score = issues.filterIsInstance().firstOrNull()?.score + val breachOccurrences = issues.filterIsInstance().firstOrNull() + ?.occurrences + ?: 0 + + fingerprints[item.id]?.let { fingerprint -> + StoredHealthItem( + id = item.id, + fingerprint = fingerprint, + score = score, + breachOccurrences = breachOccurrences, + unreadable = item.id in unreadable, + ) + } + } + + private suspend fun candidate(login: Login): Result? = + login.passwordCredential?.let { credential -> + cryptographicScopeProvider.itemScope( + itemId = login.id, + ) { + PasswordCandidate( + id = login.id, + score = credential.score, + password = credential.secret.decrypt().toCharArray(), + ) + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt new file mode 100644 index 000000000..d3d01aa77 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFind.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.password_health.domain.report + +internal class UnionFind { + private val parent = HashMap() + fun contains(x: T) = x in parent + + fun find(x: T): T { + val p = parent.getOrPut(x) { x } + return if (p == x) x else find(p).also { parent[x] = it } + } + + fun union(a: T, b: T) { + val ra = find(a) + val rb = find(b) + if (ra != rb) parent[ra] = rb + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 76439d109..8731986b7 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -1,257 +1,69 @@ package de.davis.keygo.feature.password_health.domain.usecase -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.repository.LoginRepository -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider -import de.davis.keygo.core.security.domain.crypto.decrypt -import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull -import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.LoginFingerprinter -import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker -import de.davis.keygo.feature.password_health.domain.model.HealthFinding -import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint -import de.davis.keygo.feature.password_health.domain.model.ItemHealth -import de.davis.keygo.feature.password_health.domain.model.ItemIssue -import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError -import de.davis.keygo.feature.password_health.domain.model.RelatedGroup -import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem -import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single +import kotlin.coroutines.CoroutineContext import kotlin.time.Clock -import kotlin.time.Duration.Companion.days @Single class PasswordHealthReportUseCase( private val loginRepository: LoginRepository, - private val cryptographicScopeProvider: CryptographicScopeProvider, private val loginFingerprinter: LoginFingerprinter, private val healthReportStoreRepository: HealthReportStoreRepository, private val breachCheckStateRepository: BreachCheckStateRepository, - private val checker: List, + private val scanner: HealthReportScanner, + private val assembler: HealthReportAssembler, ) { - suspend operator fun invoke(): Result = - resultBinding { - coroutineScope { - val logins = loginRepository.observeLogins() - .firstOrNull() - ?.filter { it.passwordCredential != null } - .asResult(PasswordHealthReportError.NoPasswords) - .bind() - - val fingerprints = logins.map { login -> - async { loginFingerprinter.fingerprint(login)?.let { login.id to it } } - } - .awaitAll() - .filterNotNull() - .toMap() - - val breachCheckEnabled = breachCheckStateRepository.getBreachCheckState().enabled - val storedReport = healthReportStoreRepository.load().getOrNull() - - if (storedReport != null - && storedReport.isUpToDate(fingerprints, breachCheckEnabled) - ) { - return@coroutineScope storedReport.assembleReport(logins) - } - - val report = logins.computeReport(fingerprints, breachCheckEnabled).bind() - return@coroutineScope report.assembleReport(logins) + suspend operator fun invoke( + coroutineContext: CoroutineContext = Dispatchers.Default, + ): Result = resultBinding { + withContext(coroutineContext) { + val breachCheckEnabled = async { + breachCheckStateRepository.getBreachCheckState().enabled } - } - - private fun StoredHealthReport.isUpToDate( - fingerprints: Map, - breachCheckEnabled: Boolean - ): Boolean { - val fingerprintsMatch = items.associate { it.id to it.fingerprint } == fingerprints - val expired = Clock.System.now() - generatedAt >= REPORT_TTL - - return fingerprintsMatch && !expired && this.breachCheckEnabled == breachCheckEnabled - } - - private suspend fun Collection.computeReport( - fingerprints: Map, - breachCheckEnabled: Boolean, - ) = resultBinding { - val results = coroutineScope { - map { login -> async { login.id to candidate(login) } }.awaitAll() - } - - // A password that will not decrypt is checked by nobody, so it is reported instead - // of quietly dropping out of the count. - val unreadable = results - .filter { (_, result) -> result?.isFailure() == true } - .mapTo(mutableSetOf()) { (id, _) -> id } - - val candidates = results.mapNotNull { (_, result) -> result?.getOrNull() } - candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() - - val outcomes = try { - coroutineScope { - checker.map { async { it.type to it.check(candidates) } }.awaitAll() - } - } finally { - candidates.forEach { it.password.fill('\u0000') } - } - - val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } - val gaps = outcomes.mapNotNull { (type, outcome) -> outcome.gap?.let { type to it } } - .toMap() - - val relations = findings.filterIsInstance() - val issuesById = findings.filterIsInstance() - .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) - - val report = StoredHealthReport( - items = mapToStoredHealthItems(issuesById, fingerprints, unreadable), - relationalFindings = relations, - gaps = gaps, - generatedAt = Clock.System.now(), - breachCheckEnabled = breachCheckEnabled - ) - - healthReportStoreRepository.storeReport(report) - .bind { PasswordHealthReportError.StoreFailed } - report - } - - private fun StoredHealthReport.assembleReport(logins: List): PasswordHealthReport { - val loginsById = logins.associateBy { it.id } - - val issuesById = items.filterFindings() - .filter { it.id in loginsById } - .associate { it.id to it.issues() } - - val relations = relationalFindings.mapNotNull { - val surviving = it.relatedItemIds.filterTo(mutableSetOf()) { it in loginsById } - if (surviving.size > 1) it.copy(relatedItemIds = surviving) else null - } - - val uf = UnionFind() - relations.forEach { relation -> - val first = relation.relatedItemIds.first() - relation.relatedItemIds.forEach { uf.union(first, it) } - } + val storedReport = async { healthReportStoreRepository.load().getOrNull() } - fun health(item: StoredHealthItem): ItemHealth { - val login = loginsById.getValue(item.id) - return ItemHealth( - itemId = item.id, - title = login.name, - username = login.username, - issues = issuesById[item.id].orEmpty().sortedByDescending { it.severity }, - urls = login.domainInfos.map { it.value }, - ) - } - - val (related, unrelated) = items.partition { uf.contains(it.id) } - val relationsById = relations.groupBy { uf.find(it.relatedItemIds.first()) } + val logins = loginRepository.observeLogins() + .firstOrNull() + ?.filter { it.passwordCredential != null } + .asResult(PasswordHealthReportError.NoPasswords) + .bind() - val groups = related.groupBy { uf.find(it.id) } - .map { (root, members) -> - RelatedGroup( - id = root, - members = members.mapTo(mutableSetOf(), ::health), - relations = relationsById.getValue(root).toSet(), - ) + val fingerprints = logins.map { login -> + async { loginFingerprinter.fingerprint(login)?.let { login.id to it } } } - .sortedByDescending { it.maxSeverity } - - val standalone = unrelated.map(::health) - .filter { it.issues.isNotEmpty() } - .sortedByDescending { it.maxSeverity } + .awaitAll() + .filterNotNull() + .toMap() - val survivingGaps = gaps.mapNotNull { (kind, gap) -> - val unchecked = gap.unchecked.filterTo(mutableSetOf()) { it in loginsById } - if (unchecked.isNotEmpty()) kind to gap.copy(unchecked = unchecked) else null - }.toMap() - - return PasswordHealthReport( - groups = groups, - standalone = standalone, - totalPasswordsScanned = totalPasswordsScanned, - gaps = survivingGaps, - unreadable = items.filter { it.unreadable } - .mapNotNullTo(mutableSetOf()) { it.id.takeIf { id -> id in loginsById } } - ) - } - - private fun Collection.filterFindings() = filter { - (it.score != null && !it.score.isNone) || - it.breachOccurrences > 0 - } - - private fun StoredHealthItem.issues() = buildList { - score?.takeIf { !it.isNone }?.let { add(ItemIssue.Weak(it)) } - if (breachOccurrences > 0) add(ItemIssue.Breached(breachOccurrences)) - } + val now = Clock.System.now() + storedReport.await() + ?.takeIf { it.isFreshFor(fingerprints, breachCheckEnabled.await(), now) } + ?.let { return@withContext assembler.assemble(it, logins) } - private fun Collection.mapToStoredHealthItems( - issuesById: Map>, - fingerprints: Map, - unreadable: Set, - ): List = mapNotNull { item -> - val issues = issuesById[item.id].orEmpty() - val score = issues.filterIsInstance().firstOrNull()?.score - val breachOccurrences = issues.filterIsInstance().firstOrNull() - ?.occurrences - ?: 0 + val report = scanner.scan(logins, fingerprints, breachCheckEnabled.await(), now).bind() - fingerprints[item.id]?.let { fingerprint -> - StoredHealthItem( - id = item.id, - fingerprint = fingerprint, - score = score, - breachOccurrences = breachOccurrences, - unreadable = item.id in unreadable - ) + val stored = async { healthReportStoreRepository.storeReport(report) } + val assembled = assembler.assemble(report, logins) + stored.await().bind { PasswordHealthReportError.StoreFailed } + assembled } } - - private suspend fun candidate(login: Login): Result? = - login.passwordCredential?.let { credential -> - cryptographicScopeProvider.itemScope( - itemId = login.id, - ) { - PasswordCandidate( - id = login.id, - score = credential.score, - password = credential.secret.decrypt().toCharArray(), - ) - } - } - - companion object { - private val REPORT_TTL = 1.days - } -} - -private class UnionFind { - private val parent = HashMap() - fun contains(x: T) = x in parent - - fun find(x: T): T { - val p = parent.getOrPut(x) { x } - return if (p == x) x else find(p).also { parent[x] = it } - } - - fun union(a: T, b: T) { - val ra = find(a) - val rb = find(b) - if (ra != rb) parent[ra] = rb - } } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt new file mode 100644 index 000000000..505d2e760 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class StoredHealthReportTest { + + private val generatedAt = Instant.fromEpochSeconds(1_000_000) + private val ids = List(2) { UUID(0L, it.toLong()) } + private val fingerprints = ids.associateWith(::fingerprint) + private val report = StoredHealthReport( + items = ids.map { + StoredHealthItem( + id = it, + fingerprint = fingerprint(it), + score = null, + breachOccurrences = 0, + unreadable = false, + ) + }, + relationalFindings = emptyList(), + generatedAt = generatedAt, + breachCheckEnabled = true, + ) + + @Test + fun isFreshForTheSameLoginsWithinItsLifetime() { + assertTrue(report.isFreshFor(fingerprints, true, generatedAt + 1.seconds)) + } + + @Test + fun isStaleOnceItsLifetimeRunsOut() { + assertFalse(report.isFreshFor(fingerprints, true, generatedAt + StoredHealthReport.TTL)) + } + + @Test + fun isStaleWhenAPasswordChanged() { + val changed = fingerprints + (ids[0] to HealthFingerprint(byteArrayOf(1))) + + assertFalse(report.isFreshFor(changed, true, generatedAt)) + } + + @Test + fun isStaleWhenALoginWasAdded() { + val added = fingerprints + (UUID(0L, 9L) to fingerprint(UUID(0L, 9L))) + + assertFalse(report.isFreshFor(added, true, generatedAt)) + } + + @Test + fun isStaleWhenTheBreachCheckWasToggled() { + assertFalse(report.isFreshFor(fingerprints, false, generatedAt)) + } + + private fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToByteArray()) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt new file mode 100644 index 000000000..5fe2474b1 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt @@ -0,0 +1,129 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class HealthReportAssemblerTest { + + private val assembler = HealthReportAssembler() + + @Test + fun relationsThatShareAnItemFormOneGroup() { + val report = report( + items = (0..3).map { storedItem(id(it)) }, + relations = listOf( + relation(RelationType.Reused, 0, 1), + relation(RelationType.Similar, 1, 2), + ), + ) + + val assembled = assembler.assemble(report, logins(0..3)) + + assertEquals(1, assembled.groups.size) + assertEquals( + setOf(id(0), id(1), id(2)), + assembled.groups.single().members.mapTo(mutableSetOf()) { it.itemId }, + ) + assertEquals(2, assembled.groups.single().relations.size) + } + + @Test + fun standaloneOnlyListsItemsWithAnIssue() { + val report = report( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak), + storedItem(id(1)), + storedItem(id(2), breachOccurrences = 3), + ), + ) + + val assembled = assembler.assemble(report, logins(0..2)) + + assertEquals(listOf(id(2), id(0)), assembled.standalone.map { it.itemId }) + assertEquals( + listOf(ItemIssue.Breached(3)), + assembled.standalone.first().issues, + ) + } + + @Test + fun aRelationLeftWithOneLoginIsDropped() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + relations = listOf(relation(RelationType.Reused, 0, 1)), + ) + + val assembled = assembler.assemble(report, logins(0..0)) + + assertEquals(emptyList(), assembled.groups) + } + + @Test + fun aRelationKeepsTheLoginsThatStillExist() { + val report = report( + items = (0..2).map { storedItem(id(it)) }, + relations = listOf(relation(RelationType.Reused, 0, 1, 2)), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals( + setOf(id(0), id(1)), + assembled.groups.single().relations.single().relatedItemIds, + ) + } + + @Test + fun aDeletedLoginLeavesNoTrace() { + val report = report( + items = listOf( + storedItem(id(0), breachOccurrences = 1, unreadable = true), + storedItem(id(1), breachOccurrences = 1, unreadable = true), + ), + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0))), + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0), id(1))), + ), + ) + + val assembled = assembler.assemble(report, logins(1..1)) + + assertEquals(listOf(id(1)), assembled.standalone.map { it.itemId }) + assertEquals(setOf(id(1)), assembled.unreadable) + assertEquals( + mapOf( + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(1))), + ), + assembled.gaps, + ) + } + + private fun logins(range: IntRange) = range.map { login(id(it)) } + + private fun relation(type: RelationType, vararg ids: Int) = HealthFinding.Relation( + relatedItemIds = ids.mapTo(mutableSetOf(), ::id), + type = type, + ) + + private fun report( + items: List, + relations: List = emptyList(), + gaps: Map = emptyMap(), + ) = StoredHealthReport( + items = items, + relationalFindings = relations, + gaps = gaps, + generatedAt = Instant.fromEpochSeconds(0), + breachCheckEnabled = true, + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt new file mode 100644 index 000000000..0b2e24e10 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt @@ -0,0 +1,150 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HealthReportScannerTest { + + private val itemRepository = FakeItemRepository() + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + + @Test + fun checkersSeeTheDecryptedPasswords() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0, 1) + + scan(logins, checker) + + assertEquals( + mapOf(id(0) to "password-${id(0)}", id(1) to "password-${id(1)}"), + checker.seen, + ) + } + + @Test + fun passwordsAreWipedOnceTheCheckersAreDone() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + + scan(readable(0, 1), checker) + + assertTrue(checker.candidates.all { c -> c.password.all { it == '\u0000' } }) + } + + @Test + fun findingsLandOnTheItemsTheyAreAbout() = runTest { + val checker = RecordingChecker( + CheckKind.Strength, + CheckOutcome( + findings = listOf( + HealthFinding.Item(id(0), ItemIssue.Weak(PasswordScore.Weak)), + HealthFinding.Item(id(1), ItemIssue.Breached(4)), + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + ), + ), + ) + + val report = scan(readable(0, 1), checker).success() + + assertEquals( + listOf( + storedItem(id(0), score = PasswordScore.Weak), + storedItem(id(1), breachOccurrences = 4), + ), + report.items, + ) + assertEquals( + listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + report.relationalFindings, + ) + } + + @Test + fun aSkippedCheckIsKeptAsAGapUnderItsKind() = runTest { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val breach = RecordingChecker(CheckKind.Breach, CheckOutcome(gap = gap)) + val strength = RecordingChecker(CheckKind.Strength) + + val report = scan(readable(0), breach, strength).success() + + assertEquals(mapOf(CheckKind.Breach to gap), report.gaps) + } + + @Test + fun aPasswordThatWillNotDecryptIsReportedUnreadable() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0) + login(id(1)) + + val report = scan(logins, checker).success() + + assertEquals(setOf(id(0)), checker.seen.keys) + assertEquals(listOf(false, true), report.items.map { it.unreadable }) + } + + @Test + fun nothingToCheckIsNoPasswords() = runTest { + val result = scan(listOf(login(id(0))), RecordingChecker(CheckKind.Strength)) + + assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) + } + + private suspend fun scan(logins: List, vararg checkers: PasswordHealthChecker) = + HealthReportScanner(scopeProvider, checkers.toList()).scan( + logins = logins, + fingerprints = logins.associate { it.id to fingerprint(it.id) }, + breachCheckEnabled = true, + generatedAt = Instant.fromEpochSeconds(0), + ) + + /** Logins whose item key the fake provider can find, so their passwords decrypt. */ + private fun readable(vararg ns: Int) = ns.map { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + login(id(n)) + } + + private fun Result.success(): S = assertIs>(this).success + + private class RecordingChecker( + override val type: CheckKind, + private val outcome: CheckOutcome = CheckOutcome(), + ) : PasswordHealthChecker { + + var candidates: List = emptyList() + var seen: Map = emptyMap() + + override suspend fun check(candidates: List): CheckOutcome { + this.candidates = candidates + seen = candidates.associate { it.id to it.password.concatToString() } + return outcome + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt new file mode 100644 index 000000000..1fd27af45 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.feature.password_health.domain.report + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import java.util.UUID + +internal val VAULT_ID: UUID = UUID(1L, 0L) + +internal fun id(n: Int): ItemId = UUID(0L, n.toLong()) + +/** A login whose password [FakeCryptographicScopeProvider] decrypts back to [password]. */ +internal fun login( + id: ItemId, + password: String = "password-$id", + score: PasswordScore = PasswordScore.Strong, +) = Login( + id = id, + username = "user-$id", + domainInfos = emptySet(), + passwordCredential = PasswordCredential( + secret = PasswordSecret( + EncryptedPayload( + ciphertext = FakeCryptographicScopeProvider.transform( + password.encodeToByteArray(), + ), + iv = FakeCryptographicScopeProvider.IV, + ), + ), + score = score, + ), + totp = null, + passkeys = emptySet(), + vaultId = VAULT_ID, + name = "login-$id", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + note = null, + pinned = false, +) + +internal fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToByteArray()) + +internal fun storedItem( + id: ItemId, + score: PasswordScore? = null, + breachOccurrences: Int = 0, + unreadable: Boolean = false, +) = StoredHealthItem( + id = id, + fingerprint = fingerprint(id), + score = score, + breachOccurrences = breachOccurrences, + unreadable = unreadable, +) From f8d8da1731081326e812e83e30a872d7358e06ea Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 25 Sep 2026 20:01:23 +0200 Subject: [PATCH 27/54] feat: invalidate when gap has failed reason --- .../domain/model/StoredHealthReport.kt | 4 +++ .../domain/model/StoredHealthReportTest.kt | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt index c062d4cde..4c431d8f7 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -20,9 +20,13 @@ data class StoredHealthReport( breachCheckEnabled: Boolean, now: Instant, ): Boolean = this.breachCheckEnabled == breachCheckEnabled + && !hasRetryableGap && now - generatedAt < TTL && items.associate { it.id to it.fingerprint } == fingerprints + private val hasRetryableGap: Boolean + get() = gaps.values.any { it.reason != GapReason.Disabled } + companion object { const val ALGORITHM_VERSION = 1 val TTL = 1.days diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt index 505d2e760..4b31b0d52 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt @@ -57,5 +57,33 @@ class StoredHealthReportTest { assertFalse(report.isFreshFor(fingerprints, false, generatedAt)) } + @Test + fun isStaleWhenACheckCouldNotReachItsService() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(ids[0]))), + ) + + assertFalse(incomplete.isFreshFor(fingerprints, true, generatedAt)) + } + + @Test + fun isStaleWhenACheckFailed() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(ids[0]))), + ) + + assertFalse(incomplete.isFreshFor(fingerprints, true, generatedAt)) + } + + @Test + fun staysFreshWhenACheckWasTurnedOff() { + val disabled = report.copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Disabled, ids.toSet())), + breachCheckEnabled = false, + ) + + assertTrue(disabled.isFreshFor(fingerprints, false, generatedAt)) + } + private fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToByteArray()) } From c4ef7347f4ec9097c9c417d2d3d8bc3e45aaf60e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 00:34:13 +0200 Subject: [PATCH 28/54] refactor: make scanner more readable --- .../data/mapper/StoredHealthReportMapper.kt | 14 +- .../domain/model/StoredHealthReport.kt | 36 +++- .../domain/report/HealthReportAssembler.kt | 3 + .../domain/report/HealthReportScanner.kt | 173 +++++++++++++----- .../usecase/PasswordHealthReportUseCase.kt | 12 +- .../main/proto/password_health_report.proto | 8 +- .../domain/model/StoredHealthReportTest.kt | 93 ++++++---- .../report/HealthReportAssemblerTest.kt | 8 +- .../domain/report/HealthReportScannerTest.kt | 136 ++++++++++++-- .../domain/report/ReportFixtures.kt | 9 +- 10 files changed, 373 insertions(+), 119 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt index 24a4d4b9b..b0888558c 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt @@ -14,6 +14,7 @@ import de.davis.keygo.feature.password_health.data.local.model.protoCheckGap import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport import de.davis.keygo.feature.password_health.data.local.model.protoStoredItem +import de.davis.keygo.feature.password_health.domain.model.BreachResult import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.GapReason @@ -69,7 +70,6 @@ private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { items = items, relationalFindings = relationFindingList.mapNotNull { it.toDomain(it.itemRefsList.toItemIds()) }, gaps = gaps, - generatedAt = Instant.fromEpochMilliseconds(generatedAtEpochMillis), breachCheckEnabled = breachCheckEnabled, ) } @@ -87,7 +87,6 @@ private fun StoredHealthReport.toProto(): ProtoStoredHealthReport { items += this@toProto.items.map(StoredHealthItem::toProto) relationFinding += this@toProto.relationalFindings.map { it.toProto(it.relatedItemIds.toRefs()) } gaps += this@toProto.gaps.toProto { it.unchecked.toRefs() } - generatedAtEpochMillis = generatedAt.toEpochMilliseconds() breachCheckEnabled = this@toProto.breachCheckEnabled algorithmVersion = StoredHealthReport.ALGORITHM_VERSION } @@ -98,8 +97,10 @@ private fun StoredHealthItem.toProto() = protoStoredItem { fingerprint = this@toProto.fingerprint.value.toByteString() score = this@toProto.score.toProto() - if (this@toProto.breachOccurrences > 0) - breachOccurrences = this@toProto.breachOccurrences + breach?.let { breach -> + if (breach.occurrences > 0) breachOccurrences = breach.occurrences + breachCheckedAtEpochMillis = breach.checkedAt.toEpochMilliseconds() + } unreadable = this@toProto.unreadable } @@ -108,7 +109,10 @@ private fun ProtoStoredItem.toDomain(): StoredHealthItem = StoredHealthItem( id = Uuid.fromByteArray(id.toByteArray()).toJavaUuid(), fingerprint = HealthFingerprint(fingerprint.toByteArray()), score = score.toDomain(), - breachOccurrences = breachOccurrences, + breach = if (hasBreachCheckedAtEpochMillis()) BreachResult( + occurrences = breachOccurrences, + checkedAt = Instant.fromEpochMilliseconds(breachCheckedAtEpochMillis), + ) else null, unreadable = unreadable, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt index 4c431d8f7..03ddbf07d 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -9,7 +9,6 @@ data class StoredHealthReport( val items: List, val relationalFindings: List, val gaps: Map = emptyMap(), - val generatedAt: Instant, val breachCheckEnabled: Boolean, ) { @@ -19,17 +18,32 @@ data class StoredHealthReport( fingerprints: Map, breachCheckEnabled: Boolean, now: Instant, + ): Boolean = isCurrentFor(fingerprints, breachCheckEnabled) + && (!breachCheckEnabled || items.all { it.breach?.isCurrentAt(now) == true }) + + fun isCurrentFor( + fingerprints: Map, + breachCheckEnabled: Boolean, ): Boolean = this.breachCheckEnabled == breachCheckEnabled + && items.none { it.unreadable } // if any item was unreadable, we want a new report && !hasRetryableGap - && now - generatedAt < TTL && items.associate { it.id to it.fingerprint } == fingerprints + fun breachResultsAt( + fingerprints: Map, + now: Instant, + ): Map = items + .filter { fingerprints[it.id] == it.fingerprint } + .mapNotNull { item -> item.breach?.takeIf { it.isCurrentAt(now) }?.let { item.id to it } } + .toMap() + + // TODO: maybe make this even readable, as this kinda suggests that the scanner actually does the re-checking + // A breach gap leaves its items without a result, so they are retried through breachResultsAt. private val hasRetryableGap: Boolean - get() = gaps.values.any { it.reason != GapReason.Disabled } + get() = gaps.any { (kind, gap) -> kind != CheckKind.Breach && gap.reason != GapReason.Disabled } companion object { const val ALGORITHM_VERSION = 1 - val TTL = 1.days } } @@ -37,10 +51,22 @@ data class StoredHealthItem( val id: ItemId, val fingerprint: HealthFingerprint, val score: PasswordScore?, - val breachOccurrences: Int, + val breach: BreachResult?, val unreadable: Boolean, ) +data class BreachResult( + val occurrences: Int, + val checkedAt: Instant, +) { + + fun isCurrentAt(now: Instant): Boolean = now - checkedAt < TTL + + companion object { + val TTL = 1.days + } +} + class HealthFingerprint(val value: ByteArray) { override fun equals(other: Any?): Boolean { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt index bf9d65df4..10f772354 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssembler.kt @@ -80,6 +80,9 @@ class HealthReportAssembler { it.breachOccurrences > 0 } + private val StoredHealthItem.breachOccurrences + get() = breach?.occurrences ?: 0 + private fun StoredHealthItem.issues() = buildList { score?.takeIf { !it.isNone }?.let { add(ItemIssue.Weak(it)) } if (breachOccurrences > 0) add(ItemIssue.Breached(breachOccurrences)) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt index 3a8f99018..4a4d01403 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt @@ -11,6 +11,9 @@ import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint import de.davis.keygo.feature.password_health.domain.model.ItemIssue @@ -33,68 +36,139 @@ class HealthReportScanner( suspend fun scan( logins: Collection, fingerprints: Map, + previous: StoredHealthReport?, breachCheckEnabled: Boolean, - generatedAt: Instant, + now: Instant, ): Result = resultBinding { + val keptBreaches = previous + ?.takeIf { breachCheckEnabled } + ?.breachResultsAt(fingerprints, now) + .orEmpty() + + // Strength, reuse and similarity only move with the passwords, so a report that still + // matches them is kept and only the breach lookups that ran out are repeated. + val current = previous?.takeIf { it.isCurrentFor(fingerprints, breachCheckEnabled) } + + if (current != null) refreshBreaches(current, logins, keptBreaches, now) + else fullScan(logins, fingerprints, keptBreaches, breachCheckEnabled, now).bind() + } + + private suspend fun fullScan( + logins: Collection, + fingerprints: Map, + keptBreaches: Map, + breachCheckEnabled: Boolean, + now: Instant, + ): Result = resultBinding { + val decrypted = decrypt(logins) + decrypted.candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + + val outcomes = check(checkers, decrypted.candidates, keptBreaches) + val findings = outcomes.values.flatMap { it.findings } + val weak = findings.issues() + val breaches = outcomes.breachResults(decrypted.candidates, keptBreaches, now) + + StoredHealthReport( + items = logins.mapNotNull { login -> + fingerprints[login.id]?.let { fingerprint -> + StoredHealthItem( + id = login.id, + fingerprint = fingerprint, + score = weak[login.id]?.score, + breach = breaches[login.id], + unreadable = login.id in decrypted.unreadable, + ) + } + }, + relationalFindings = findings.filterIsInstance(), + gaps = outcomes.gaps(), + breachCheckEnabled = breachCheckEnabled, + ) + } + + private suspend fun refreshBreaches( + current: StoredHealthReport, + logins: Collection, + keptBreaches: Map, + now: Instant, + ): StoredHealthReport { + val decrypted = decrypt(logins.filter { it.id !in keptBreaches }) + val breachCheckers = checkers.filter { it.type == CheckKind.Breach } + + val outcomes = check(breachCheckers, decrypted.candidates, keptBreaches) + val breaches = outcomes.breachResults(decrypted.candidates, keptBreaches, now) + + return current.copy( + items = current.items.map { + it.copy(breach = breaches[it.id], unreadable = it.id in decrypted.unreadable) + }, + gaps = current.gaps - CheckKind.Breach + outcomes.gaps(), + ) + } + + private suspend fun decrypt(logins: Collection): Decrypted { val results = coroutineScope { logins.map { login -> async { login.id to candidate(login) } }.awaitAll() } // A password that will not decrypt is checked by nobody, so it is reported instead // of quietly dropping out of the count. - val unreadable = results - .filter { (_, result) -> result?.isFailure() == true } - .mapTo(mutableSetOf()) { (id, _) -> id } + return Decrypted( + candidates = results.mapNotNull { (_, result) -> result?.getOrNull() }, + unreadable = results + .filter { (_, result) -> result?.isFailure() == true } + .mapTo(mutableSetOf()) { (id, _) -> id }, + ) + } - val candidates = results.mapNotNull { (_, result) -> result?.getOrNull() } - candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + private suspend fun check( + checkers: List, + candidates: List, + keptBreaches: Map, + ): Map = try { + coroutineScope { + checkers.map { checker -> + async { + // Without the filter, the breach checker would look up every password again + // after any edit, and incremental breach lookups would no longer happen. + val input = if (checker.type == CheckKind.Breach) + candidates.filter { it.id !in keptBreaches } + else candidates - val outcomes = try { - coroutineScope { - checkers.map { async { it.type to it.check(candidates) } }.awaitAll() - } - } finally { - candidates.forEach { it.password.fill('\u0000') } + checker.type to checker.check(input) + } + }.awaitAll().toMap() } + } finally { + candidates.forEach { it.password.fill('\u0000') } + } - val findings = outcomes.flatMap { (_, outcome) -> outcome.findings } - val gaps = outcomes.mapNotNull { (type, outcome) -> outcome.gap?.let { type to it } } - .toMap() - - val relations = findings.filterIsInstance() - val issuesById = findings.filterIsInstance() - .groupBy(keySelector = { it.id }, valueTransform = { it.issue }) + private fun Map.breachResults( + candidates: List, + keptBreaches: Map, + now: Instant, + ): Map { + val outcome = this[CheckKind.Breach] ?: return keptBreaches + val breached = outcome.findings.issues() + val unchecked = outcome.gap?.unchecked.orEmpty() - StoredHealthReport( - items = logins.mapToStoredHealthItems(issuesById, fingerprints, unreadable), - relationalFindings = relations, - gaps = gaps, - generatedAt = generatedAt, - breachCheckEnabled = breachCheckEnabled, - ) + return keptBreaches + candidates.map { it.id } + .filter { it !in keptBreaches && it !in unchecked } + .associateWith { + BreachResult( + occurrences = breached[it]?.occurrences ?: 0, + checkedAt = now + ) + } } - private fun Collection.mapToStoredHealthItems( - issuesById: Map>, - fingerprints: Map, - unreadable: Set, - ): List = mapNotNull { item -> - val issues = issuesById[item.id].orEmpty() - val score = issues.filterIsInstance().firstOrNull()?.score - val breachOccurrences = issues.filterIsInstance().firstOrNull() - ?.occurrences - ?: 0 - - fingerprints[item.id]?.let { fingerprint -> - StoredHealthItem( - id = item.id, - fingerprint = fingerprint, - score = score, - breachOccurrences = breachOccurrences, - unreadable = item.id in unreadable, - ) - } - } + private fun Map.gaps() = + mapNotNull { (kind, outcome) -> outcome.gap?.let { kind to it } }.toMap() + + private inline fun List.issues(): Map = + filterIsInstance() + .mapNotNull { finding -> (finding.issue as? T)?.let { finding.id to it } } + .toMap() private suspend fun candidate(login: Login): Result? = login.passwordCredential?.let { credential -> @@ -108,4 +182,9 @@ class HealthReportScanner( ) } } + + private class Decrypted( + val candidates: List, + val unreadable: Set, + ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 8731986b7..a170cb4ea 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -54,11 +54,17 @@ class PasswordHealthReportUseCase( .toMap() val now = Clock.System.now() - storedReport.await() - ?.takeIf { it.isFreshFor(fingerprints, breachCheckEnabled.await(), now) } + val previous = storedReport.await() + previous?.takeIf { it.isFreshFor(fingerprints, breachCheckEnabled.await(), now) } ?.let { return@withContext assembler.assemble(it, logins) } - val report = scanner.scan(logins, fingerprints, breachCheckEnabled.await(), now).bind() + val report = scanner.scan( + logins = logins, + fingerprints = fingerprints, + previous = previous, + breachCheckEnabled = breachCheckEnabled.await(), + now = now, + ).bind() val stored = async { healthReportStoreRepository.storeReport(report) } val assembled = assembler.assemble(report, logins) diff --git a/feature/password-health/src/main/proto/password_health_report.proto b/feature/password-health/src/main/proto/password_health_report.proto index e4a3094cc..b46b9471e 100644 --- a/feature/password-health/src/main/proto/password_health_report.proto +++ b/feature/password-health/src/main/proto/password_health_report.proto @@ -14,17 +14,17 @@ message ProtoStoredHealthReport { repeated ProtoStoredItem items = 1; repeated ProtoRelationFinding relation_finding = 2; repeated ProtoCheckGap gaps = 3; - int64 generated_at_epoch_millis = 4; - int32 algorithm_version = 5; - bool breach_check_enabled = 6; + int32 algorithm_version = 4; + bool breach_check_enabled = 5; } message ProtoStoredItem { bytes id = 1; // 16 raw bytes of the UUID bytes fingerprint = 2; // raw digest, not hex/base64 - optional int32 breach_occurrences = 3; // unset = never looked up + optional int32 breach_occurrences = 3; ProtoPasswordScore score = 4; bool unreadable = 5; + optional int64 breach_checked_at_epoch_millis = 6; // unset = never looked up } message ProtoRelationFinding { diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt index 4b31b0d52..ff2e34e80 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt @@ -1,89 +1,114 @@ package de.davis.keygo.feature.password_health.domain.model -import de.davis.keygo.core.item.domain.alias.ItemId -import java.util.UUID +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.fingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class StoredHealthReportTest { - private val generatedAt = Instant.fromEpochSeconds(1_000_000) - private val ids = List(2) { UUID(0L, it.toLong()) } + private val checkedAt = Instant.fromEpochSeconds(1_000_000) + private val ids = List(2) { id(it) } private val fingerprints = ids.associateWith(::fingerprint) private val report = StoredHealthReport( - items = ids.map { - StoredHealthItem( - id = it, - fingerprint = fingerprint(it), - score = null, - breachOccurrences = 0, - unreadable = false, - ) - }, + items = ids.map { storedItem(it, breach = breach(0, checkedAt)) }, relationalFindings = emptyList(), - generatedAt = generatedAt, breachCheckEnabled = true, ) @Test - fun isFreshForTheSameLoginsWithinItsLifetime() { - assertTrue(report.isFreshFor(fingerprints, true, generatedAt + 1.seconds)) + fun isFreshForTheSameLoginsWhileTheBreachLookupsHold() { + assertTrue(report.isFreshFor(fingerprints, true, checkedAt + 1.seconds)) } @Test - fun isStaleOnceItsLifetimeRunsOut() { - assertFalse(report.isFreshFor(fingerprints, true, generatedAt + StoredHealthReport.TTL)) + fun isStaleOnceABreachLookupRunsOut() { + assertFalse(report.isFreshFor(fingerprints, true, checkedAt + BreachResult.TTL)) + } + + @Test + fun anExpiredBreachLookupLeavesTheOtherChecksCurrent() { + assertTrue(report.isCurrentFor(fingerprints, true)) + } + + @Test + fun withoutTheBreachCheckItNeverExpires() { + val disabled = report.copy( + items = ids.map { storedItem(it) }, + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Disabled, ids.toSet())), + breachCheckEnabled = false, + ) + + assertTrue(disabled.isFreshFor(fingerprints, false, checkedAt + 365.days)) } @Test fun isStaleWhenAPasswordChanged() { val changed = fingerprints + (ids[0] to HealthFingerprint(byteArrayOf(1))) - assertFalse(report.isFreshFor(changed, true, generatedAt)) + assertFalse(report.isCurrentFor(changed, true)) } @Test fun isStaleWhenALoginWasAdded() { - val added = fingerprints + (UUID(0L, 9L) to fingerprint(UUID(0L, 9L))) + val added = fingerprints + (id(9) to fingerprint(id(9))) - assertFalse(report.isFreshFor(added, true, generatedAt)) + assertFalse(report.isCurrentFor(added, true)) } @Test fun isStaleWhenTheBreachCheckWasToggled() { - assertFalse(report.isFreshFor(fingerprints, false, generatedAt)) + assertFalse(report.isCurrentFor(fingerprints, false)) + } + + @Test + fun isStaleWhileAPasswordCouldNotBeRead() { + val unreadable = report.copy(items = listOf(storedItem(ids[0], unreadable = true))) + + assertFalse(unreadable.isCurrentFor(mapOf(ids[0] to fingerprint(ids[0])), true)) } @Test - fun isStaleWhenACheckCouldNotReachItsService() { + fun anUnreachableBreachLookupIsRetriedWithoutTheOtherChecks() { val incomplete = report.copy( + items = listOf(storedItem(ids[0]), report.items[1]), gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(ids[0]))), ) - assertFalse(incomplete.isFreshFor(fingerprints, true, generatedAt)) + assertFalse(incomplete.isFreshFor(fingerprints, true, checkedAt)) + assertTrue(incomplete.isCurrentFor(fingerprints, true)) } @Test - fun isStaleWhenACheckFailed() { + fun isStaleWhenAnotherCheckFailed() { val incomplete = report.copy( - gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(ids[0]))), + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(ids[0]))), ) - assertFalse(incomplete.isFreshFor(fingerprints, true, generatedAt)) + assertFalse(incomplete.isCurrentFor(fingerprints, true)) } @Test - fun staysFreshWhenACheckWasTurnedOff() { - val disabled = report.copy( - gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Disabled, ids.toSet())), - breachCheckEnabled = false, + fun breachResultsOnlyCarryOverForUnchangedPasswordsStillInDate() { + val mixed = report.copy( + items = listOf( + storedItem(ids[0], breach = breach(2, checkedAt)), + storedItem(ids[1], breach = breach(0, checkedAt - BreachResult.TTL)), + storedItem(id(2), breach = breach(5, checkedAt)), + ), ) + val current = fingerprints + (id(2) to HealthFingerprint(byteArrayOf(1))) - assertTrue(disabled.isFreshFor(fingerprints, false, generatedAt)) + assertEquals( + mapOf(ids[0] to breach(2, checkedAt)), + mixed.breachResultsAt(current, checkedAt) + ) } - - private fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToByteArray()) } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt index 5fe2474b1..696c0b177 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt @@ -11,7 +11,6 @@ import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.time.Instant class HealthReportAssemblerTest { @@ -43,7 +42,7 @@ class HealthReportAssemblerTest { items = listOf( storedItem(id(0), score = PasswordScore.Weak), storedItem(id(1)), - storedItem(id(2), breachOccurrences = 3), + storedItem(id(2), breach = breach(3)), ), ) @@ -87,8 +86,8 @@ class HealthReportAssemblerTest { fun aDeletedLoginLeavesNoTrace() { val report = report( items = listOf( - storedItem(id(0), breachOccurrences = 1, unreadable = true), - storedItem(id(1), breachOccurrences = 1, unreadable = true), + storedItem(id(0), breach = breach(1), unreadable = true), + storedItem(id(1), breach = breach(1), unreadable = true), ), gaps = mapOf( CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0))), @@ -123,7 +122,6 @@ class HealthReportAssemblerTest { items = items, relationalFindings = relations, gaps = gaps, - generatedAt = Instant.fromEpochSeconds(0), breachCheckEnabled = true, ) } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt index 0b2e24e10..b8e23a4fb 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt @@ -9,15 +9,18 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -55,23 +58,26 @@ class HealthReportScannerTest { @Test fun findingsLandOnTheItemsTheyAreAbout() = runTest { - val checker = RecordingChecker( + val strength = RecordingChecker( CheckKind.Strength, CheckOutcome( findings = listOf( HealthFinding.Item(id(0), ItemIssue.Weak(PasswordScore.Weak)), - HealthFinding.Item(id(1), ItemIssue.Breached(4)), HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), ), ), ) + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(findings = listOf(HealthFinding.Item(id(1), ItemIssue.Breached(4)))), + ) - val report = scan(readable(0, 1), checker).success() + val report = scan(readable(0, 1), strength, breachCheck).success() assertEquals( listOf( - storedItem(id(0), score = PasswordScore.Weak), - storedItem(id(1), breachOccurrences = 4), + storedItem(id(0), score = PasswordScore.Weak, breach = breach(0, NOW)), + storedItem(id(1), breach = breach(4, NOW)), ), report.items, ) @@ -84,10 +90,10 @@ class HealthReportScannerTest { @Test fun aSkippedCheckIsKeptAsAGapUnderItsKind() = runTest { val gap = CheckGap(GapReason.Unreachable, setOf(id(0))) - val breach = RecordingChecker(CheckKind.Breach, CheckOutcome(gap = gap)) + val breachCheck = RecordingChecker(CheckKind.Breach, CheckOutcome(gap = gap)) val strength = RecordingChecker(CheckKind.Strength) - val report = scan(readable(0), breach, strength).success() + val report = scan(readable(0), breachCheck, strength).success() assertEquals(mapOf(CheckKind.Breach to gap), report.gaps) } @@ -103,6 +109,90 @@ class HealthReportScannerTest { assertEquals(listOf(false, true), report.items.map { it.unreadable }) } + @Test + fun anUnreachableLookupLeavesItsItemsWithoutABreachResult() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(gap = CheckGap(GapReason.Unreachable, setOf(id(0)))), + ) + + val report = scan(readable(0, 1), breachCheck).success() + + assertEquals(listOf(null, breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun anUnchangedVaultOnlyRepeatsTheBreachLookupsThatRanOut() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val relation = HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Similar) + val previous = report( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW - BreachResult.TTL)), + relations = listOf(relation), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertTrue(strength.candidates.isEmpty()) + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals( + listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)), + ), + report.items, + ) + assertEquals(listOf(relation), report.relationalFindings) + } + + @Test + fun aChangedPasswordRerunsTheLocalChecksButOnlyItsOwnBreachLookup() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val previous = report( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)).copy( + fingerprint = HealthFingerprint( + byteArrayOf(1) + ) + ), + relations = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Similar)), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertEquals(setOf(id(0), id(1)), strength.seen.keys) + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals( + listOf( + storedItem(id(0), breach = breach(1, NOW)), + storedItem(id(1), breach = breach(0, NOW)), + ), + report.items, + ) + assertTrue(report.relationalFindings.isEmpty()) + } + + @Test + fun turningTheBreachCheckOffDropsTheKeptLookups() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(gap = CheckGap(GapReason.Disabled, setOf(id(0)))), + ) + val previous = report(storedItem(id(0), breach = breach(3, NOW))) + + val report = scan( + readable(0), + breachCheck, + previous = previous, + breachCheckEnabled = false, + ).success() + + assertEquals(setOf(id(0)), breachCheck.seen.keys) + assertEquals(listOf(storedItem(id(0))), report.items) + } + @Test fun nothingToCheckIsNoPasswords() = runTest { val result = scan(listOf(login(id(0))), RecordingChecker(CheckKind.Strength)) @@ -110,13 +200,27 @@ class HealthReportScannerTest { assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) } - private suspend fun scan(logins: List, vararg checkers: PasswordHealthChecker) = - HealthReportScanner(scopeProvider, checkers.toList()).scan( - logins = logins, - fingerprints = logins.associate { it.id to fingerprint(it.id) }, - breachCheckEnabled = true, - generatedAt = Instant.fromEpochSeconds(0), - ) + private suspend fun scan( + logins: List, + vararg checkers: PasswordHealthChecker, + previous: StoredHealthReport? = null, + breachCheckEnabled: Boolean = true, + ) = HealthReportScanner(scopeProvider, checkers.toList()).scan( + logins = logins, + fingerprints = logins.associate { it.id to fingerprint(it.id) }, + previous = previous, + breachCheckEnabled = breachCheckEnabled, + now = NOW, + ) + + private fun report( + vararg items: StoredHealthItem, + relations: List = emptyList(), + ) = StoredHealthReport( + items = items.toList(), + relationalFindings = relations, + breachCheckEnabled = true, + ) /** Logins whose item key the fake provider can find, so their passwords decrypt. */ private fun readable(vararg ns: Int) = ns.map { n -> @@ -147,4 +251,8 @@ class HealthReportScannerTest { return outcome } } + + private companion object { + val NOW = Instant.fromEpochSeconds(1_000_000) + } } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt index 1fd27af45..48db13a3a 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/ReportFixtures.kt @@ -9,9 +9,11 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.feature.password_health.domain.model.BreachResult import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import java.util.UUID +import kotlin.time.Instant internal val VAULT_ID: UUID = UUID(1L, 0L) @@ -52,12 +54,15 @@ internal fun fingerprint(id: ItemId) = HealthFingerprint(id.toString().encodeToB internal fun storedItem( id: ItemId, score: PasswordScore? = null, - breachOccurrences: Int = 0, + breach: BreachResult? = null, unreadable: Boolean = false, ) = StoredHealthItem( id = id, fingerprint = fingerprint(id), score = score, - breachOccurrences = breachOccurrences, + breach = breach, unreadable = unreadable, ) + +internal fun breach(occurrences: Int, checkedAt: Instant = Instant.fromEpochSeconds(0)) = + BreachResult(occurrences = occurrences, checkedAt = checkedAt) From 8dd72691a52677439d95a4304072b3b227d2cce7 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 00:49:23 +0200 Subject: [PATCH 29/54] chore: add comment --- .../data/repository/HealthReportStoreRepositoryImpl.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt index 9ae303475..8ffd79e06 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -79,6 +79,8 @@ internal class HealthReportStoreRepositoryImpl( ) companion object { + // On-disk identity: the AAD domain separator for every stored report. Never change it; + // doing so makes every previously stored report permanently undecryptable. private val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") private const val REPORT_LABEL = "password_health_report" From 92674117bae234495611ee6a73eb41cc87bdeebf Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 00:53:12 +0200 Subject: [PATCH 30/54] fix: dont fail report when cache failed --- .../domain/model/PasswordHealthReportError.kt | 1 - .../domain/usecase/PasswordHealthReportUseCase.kt | 15 +++++++++++---- .../presentation/model/PasswordHealthUiState.kt | 1 - 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt index 3e1959528..f476f9582 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReportError.kt @@ -3,5 +3,4 @@ package de.davis.keygo.feature.password_health.domain.model sealed interface PasswordHealthReportError { data object NoPasswords : PasswordHealthReportError data object Unreadable : PasswordHealthReportError - data object StoreFailed : PasswordHealthReportError } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index a170cb4ea..ef0f10854 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -1,9 +1,11 @@ package de.davis.keygo.feature.password_health.domain.usecase +import android.util.Log import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.LoginFingerprinter import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport @@ -66,10 +68,15 @@ class PasswordHealthReportUseCase( now = now, ).bind() - val stored = async { healthReportStoreRepository.storeReport(report) } - val assembled = assembler.assemble(report, logins) - stored.await().bind { PasswordHealthReportError.StoreFailed } - assembled + // The store is only a cache, so failing to write it must not cost the user the report. + healthReportStoreRepository.storeReport(report).onFailure { + Log.w(TAG, "Failed to store password health report: $it") + } + assembler.assemble(report, logins) } } + + companion object { + private const val TAG = "PasswordHealthReportUseCase" + } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 5073a97b0..aed48c631 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -223,7 +223,6 @@ internal fun PasswordHealthUiState.coverageNote(): String? { @ReadOnlyComposable private fun PasswordHealthReportError?.failureMessage(): String = when (this) { PasswordHealthReportError.Unreadable -> stringResource(R.string.password_health_unreadable) - PasswordHealthReportError.StoreFailed, PasswordHealthReportError.NoPasswords, null -> stringResource(R.string.password_health_check_failed) } From b376d54c98f6f1f09e087c568a92f5d6eaf1e23a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 01:03:22 +0200 Subject: [PATCH 31/54] test: pass vault manager to crypto scope provider in tests --- .../core/security/crypto/CryptographicScopeImplTest.kt | 10 ++++++++-- .../data/crypto/CryptographicScopeProviderImplTest.kt | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt index 7e9def141..731704125 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.core.util.getOrNull import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -35,8 +36,13 @@ class CryptographicScopeImplTest { private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() - private val provider = - CryptographicScopeProviderImpl(session, itemRepository, itemManager, keyWrapper) + private val provider = CryptographicScopeProviderImpl( + session, + itemRepository, + itemManager, + keyWrapper, + FakeVaultManager(), + ) private val label = "password" diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt index bb3289760..578657cb5 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt @@ -13,6 +13,7 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.test.runTest @@ -29,6 +30,7 @@ class CryptographicScopeProviderImplTest { itemRepository = FakeItemRepository(), itemManager = FakeItemManager(), keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), ) private fun wrappedVaultKeyInformation(vaultId: java.util.UUID) = WrappedVaultKeyInformation( From 2428c3a169189c895aa360442714fb66f2f7c066 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 01:34:15 +0200 Subject: [PATCH 32/54] test: expand password health test coverage --- feature/password-health/build.gradle.kts | 3 + .../domain/report/HealthReportScanner.kt | 5 +- .../FakeBreachCheckStateRepository.kt | 19 + .../password_health/FakeBreachedRepository.kt | 35 ++ .../data/LoginFingerprinterImplTest.kt | 118 +++++ .../mapper/StoredHealthReportMapperTest.kt | 319 +++++++++++++ .../BreachCheckStateRepositoryImplTest.kt | 62 +++ .../repository/BreachedRepositoryImplTest.kt | 360 ++++++++++++++ .../data/repository/FakeDataStore.kt | 15 + .../HealthReportStoreRepositoryImplTest.kt | 228 +++++++++ .../checker/BreachedPasswordCheckTest.kt | 198 ++++++++ .../domain/checker/ReusePasswordCheckTest.kt | 94 ++++ .../domain/checker/WeakPasswordCheckerTest.kt | 91 ++++ .../domain/model/ItemHealthTest.kt | 82 ++++ .../domain/model/RelatedGroupTest.kt | 49 ++ .../domain/model/StoredHealthReportTest.kt | 97 ++++ .../report/HealthReportAssemblerTest.kt | 156 ++++++ .../domain/report/HealthReportScannerTest.kt | 230 ++++++++- .../domain/report/UnionFindTest.kt | 76 +++ .../PasswordHealthReportUseCaseTest.kt | 351 ++++++++++++++ .../PasswordHealthViewModelTest.kt | 445 ++++++++++++++++++ .../model/PasswordHealthUiStateTest.kt | 255 ++++++++++ 22 files changed, 3286 insertions(+), 2 deletions(-) create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index 2210f6032..d0d61ffda 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -26,6 +26,9 @@ dependencies { implementation(libs.androidx.datastore) + testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + testImplementation(libs.robolectric) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt index 4a4d01403..2a7bc2cf5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScanner.kt @@ -61,7 +61,10 @@ class HealthReportScanner( now: Instant, ): Result = resultBinding { val decrypted = decrypt(logins) - decrypted.candidates.isNotEmpty().asResult(PasswordHealthReportError.NoPasswords).bind() + decrypted.candidates.isNotEmpty().asResult( + if (decrypted.unreadable.isEmpty()) PasswordHealthReportError.NoPasswords + else PasswordHealthReportError.Unreadable, + ).bind() val outcomes = check(checkers, decrypted.candidates, keptBreaches) val findings = outcomes.values.flatMap { it.findings } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt new file mode 100644 index 000000000..5092b3e69 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.model.BreachCheckState +import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FakeBreachCheckStateRepository(enabled: Boolean = true) : BreachCheckStateRepository { + + val state = MutableStateFlow(BreachCheckState(enabled = enabled)) + + override fun observeBreachCheckState(): Flow = state + + override suspend fun getBreachCheckState(): BreachCheckState = state.value + + override suspend fun setBreachEnabled(enabled: Boolean) { + state.value = BreachCheckState(enabled = enabled) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt new file mode 100644 index 000000000..c6dca8b9e --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachedRepository.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository +import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import java.util.Collections + +/** + * Answers range lookups from [breaches], keyed by the full upper-case SHA-1 hex of a password. + * A prefix in [failures] fails with its error instead. + */ +internal class FakeBreachedRepository( + var breaches: Map = emptyMap(), + val failures: MutableMap = mutableMapOf(), +) : BreachedRepository { + + data class Call(val prefix: String, val suffixes: Set) + + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + + override suspend fun occurrences( + prefix: String, + suffixes: Set, + ): Result, BreachedError> { + calls += Call(prefix, suffixes) + failures[prefix]?.let { return Result.Failure(it) } + + return Result.Success( + breaches + .filterKeys { it.startsWith(prefix) && it.substring(PREFIX_LENGTH) in suffixes } + .mapKeys { (hash, _) -> hash.substring(PREFIX_LENGTH) }, + ) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt new file mode 100644 index 000000000..8d4b682d7 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/LoginFingerprinterImplTest.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.password_health.data + +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.feature.password_health.domain.model.HealthFingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import kotlinx.coroutines.test.runTest +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class LoginFingerprinterImplTest { + + private val fingerprinter = LoginFingerprinterImpl() + + @Test + fun aLoginWithoutAPasswordHasNoFingerprint() = runTest { + val login = login(id(0)).copy(passwordCredential = null) + + assertNull(fingerprinter.fingerprint(login)) + } + + @Test + fun isTheSha256OfTheIdAndTheLengthPrefixedPayload() = runTest { + val login = login(id(1)).withPayload(byteArrayOf(1, 2, 3), byteArrayOf(9, 8)) + + val expected = MessageDigest.getInstance("SHA-256").run { + update(ByteArray(15) + byteArrayOf(1)) + update(ByteBuffer.allocate(4).putInt(3).array()) + update(byteArrayOf(1, 2, 3)) + update(ByteBuffer.allocate(4).putInt(2).array()) + update(byteArrayOf(9, 8)) + digest() + } + + assertEquals(HealthFingerprint(expected), fingerprinter.fingerprint(login)) + } + + @Test + fun isStableForTheSameLogin() = runTest { + val login = login(id(0)) + + assertEquals(fingerprinter.fingerprint(login), fingerprinter.fingerprint(login)) + } + + @Test + fun isA32ByteDigest() = runTest { + assertEquals(32, assertNotNull(fingerprinter.fingerprint(login(id(0)))).value.size) + } + + @Test + fun ignoresEverythingButTheIdAndThePasswordPayload() = runTest { + val login = login(id(0)) + val edited = login.copy( + name = "renamed", + username = "someone-else", + note = "a note", + pinned = true, + passwordCredential = login.passwordCredential!!.copy(score = PasswordScore.Weak), + ) + + assertEquals(fingerprinter.fingerprint(login), fingerprinter.fingerprint(edited)) + } + + @Test + fun changesWithTheCiphertext() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(0)).withPayload(byteArrayOf(2), byteArrayOf(0)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun changesWithTheIv() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(1)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun changesWithTheId() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(0)) + val b = login(id(1)).withPayload(byteArrayOf(1), byteArrayOf(0)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun movingABytePastTheCiphertextBoundaryChangesIt() = runTest { + val a = login(id(0)).withPayload(byteArrayOf(1, 2), byteArrayOf(3)) + val b = login(id(0)).withPayload(byteArrayOf(1), byteArrayOf(2, 3)) + + assertNotEquals(fingerprinter.fingerprint(a), fingerprinter.fingerprint(b)) + } + + @Test + fun anEmptyPayloadStillHasAFingerprint() = runTest { + val login = login(id(0)).withPayload(byteArrayOf(), byteArrayOf()) + + assertNotNull(fingerprinter.fingerprint(login)) + } + + private fun Login.withPayload(ciphertext: ByteArray, iv: ByteArray) = copy( + passwordCredential = PasswordCredential( + secret = PasswordSecret(EncryptedPayload(ciphertext = ciphertext, iv = iv)), + score = PasswordScore.Strong, + ), + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt new file mode 100644 index 000000000..923eca875 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt @@ -0,0 +1,319 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.ProtoStoredHealthReportKt +import de.davis.keygo.feature.password_health.data.local.model.protoCheckGap +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.local.model.protoStoredItem +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.UUID +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +class StoredHealthReportMapperTest { + + private val checkedAt = Instant.fromEpochMilliseconds(1_700_000_000_123) + + @Test + fun aFullReportSurvivesTheRoundTrip() { + val report = StoredHealthReport( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(12, checkedAt)), + storedItem(id(1), score = PasswordScore.Ridiculous), + storedItem(id(2), breach = breach(0, checkedAt)), + storedItem(id(3), unreadable = true), + ), + relationalFindings = listOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2), id(3)), RelationType.Similar), + ), + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(3))), + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0), id(2))), + ), + breachCheckEnabled = true, + ) + + assertEquals(report, report.roundTrip()) + } + + @Test + fun anEmptyReportSurvivesTheRoundTrip() { + val report = StoredHealthReport( + items = emptyList(), + relationalFindings = emptyList(), + breachCheckEnabled = false, + ) + + assertEquals(report, report.roundTrip()) + } + + @Test + fun everyScoreSurvivesTheRoundTrip() { + val scores = listOf( + PasswordScore.Ridiculous, + PasswordScore.Weak, + PasswordScore.Moderate, + PasswordScore.Strong, + PasswordScore.Excellent, + ) + val report = reportOf(scores.mapIndexed { i, score -> storedItem(id(i), score = score) }) + + assertEquals(scores, report.roundTrip().items.map { it.score }) + } + + @Test + fun aNoneScoreIsReadBackAsNoScore() { + val report = reportOf(listOf(storedItem(id(0), score = PasswordScore.None))) + + assertNull(report.roundTrip().items.single().score) + } + + @Test + fun everyGapKindAndReasonSurvivesTheRoundTrip() { + val kinds = listOf(CheckKind.Strength, CheckKind.Reuse, CheckKind.Breach, CheckKind.Similarity) + val reasons = listOf(GapReason.Disabled, GapReason.Unreachable, GapReason.Failed) + + reasons.forEach { reason -> + val report = reportOf( + items = listOf(storedItem(id(0))), + gaps = kinds.associateWith { CheckGap(reason, setOf(id(0))) }, + ) + + assertEquals(report.gaps, report.roundTrip().gaps) + } + } + + @Test + fun aCleanBreachLookupIsToldApartFromNoLookup() { + val report = reportOf( + listOf( + storedItem(id(0), breach = breach(0, checkedAt)), + storedItem(id(1), breach = null), + ), + ) + + assertEquals(listOf(breach(0, checkedAt), null), report.roundTrip().items.map { it.breach }) + } + + @Test + fun theLookupTimeKeepsMillisecondPrecision() { + val precise = Instant.fromEpochSeconds(1_700_000_000, 123_456_789) + val report = reportOf(listOf(storedItem(id(0), breach = breach(1, precise)))) + + assertEquals( + Instant.fromEpochMilliseconds(precise.toEpochMilliseconds()), + report.roundTrip().items.single().breach?.checkedAt, + ) + } + + @Test + fun aRandomIdSurvivesTheRoundTrip() { + val random = UUID.randomUUID() + val report = reportOf(listOf(storedItem(random))) + + assertEquals(random, report.roundTrip().items.single().id) + } + + @Test + fun theStoredFormIsGzipped() { + val bytes = reportOf(listOf(storedItem(id(0)))).toCompressedByteArray() + + assertEquals(listOf(0x1f.toByte(), 0x8b.toByte()), bytes.take(2)) + } + + @Test + fun theStoredFormCarriesTheAlgorithmVersion() { + val bytes = reportOf(listOf(storedItem(id(0)))).toCompressedByteArray() + + assertEquals(StoredHealthReport.ALGORITHM_VERSION, bytes.unzipProto().algorithmVersion) + } + + @Test + fun aReportFromAnotherAlgorithmVersionIsDiscarded() { + val bytes = protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + 1 + }.gzip() + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun aReportWithoutAnyVersionIsDiscarded() { + assertNull(protoStoredHealthReport { }.gzip().toStoredHealthReport()) + } + + @Test + fun aGapOfAnUnknownKindIsSkipped() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { + kindValue = 99 + uncheckedItemRefs += 0 + } + } + + assertEquals(emptyMap(), assertNotNullReport(bytes).gaps) + } + + @Test + fun aGapForAnUnknownReasonIsSkipped() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { + reasonValue = 99 + uncheckedItemRefs += 0 + } + } + + assertEquals(emptyMap(), assertNotNullReport(bytes).gaps) + } + + @Test + fun aRelationOfAnUnknownTypeIsSkipped() { + val bytes = versioned { + items += item(0) + items += item(1) + relationFinding += protoRelationFinding { + itemRefs += listOf(0, 1) + typeValue = 99 + } + } + + assertEquals(emptyList(), assertNotNullReport(bytes).relationalFindings) + } + + @Test + fun anUnknownScoreIsReadAsNoScore() { + val bytes = versioned { + items += protoStoredItem { + id = ByteArray(16).toByteString() + fingerprint = byteArrayOf(1).toByteString() + scoreValue = 99 + } + } + + assertNull(assertNotNullReport(bytes).items.single().score) + } + + @Test + fun aRelationPointingPastTheItemsIsRejected() { + val bytes = versioned { + items += item(0) + relationFinding += protoRelationFinding { itemRefs += listOf(0, 5) } + } + + assertFailsWith { bytes.toStoredHealthReport() } + } + + @Test + fun aGapPointingPastTheItemsIsRejected() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { uncheckedItemRefs += 1 } + } + + assertFailsWith { bytes.toStoredHealthReport() } + } + + @Test + fun twoGapsForTheSameKindAreRejected() { + val bytes = versioned { + items += item(0) + gaps += protoCheckGap { uncheckedItemRefs += 0 } + gaps += protoCheckGap { uncheckedItemRefs += 0 } + } + + assertFailsWith { bytes.toStoredHealthReport() } + } + + @Test + fun anItemListedTwiceCannotBeStored() { + val report = reportOf(listOf(storedItem(id(0)), storedItem(id(0)))) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun aRelationAboutAnItemNotInTheReportCannotBeStored() { + val report = reportOf( + items = listOf(storedItem(id(0))), + relations = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + ) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun aGapAboutAnItemNotInTheReportCannotBeStored() { + val report = reportOf( + items = listOf(storedItem(id(0))), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(id(1)))), + ) + + assertFailsWith { report.toCompressedByteArray() } + } + + @Test + fun bytesThatAreNotGzipAreRejected() { + assertFailsWith { byteArrayOf(1, 2, 3).toStoredHealthReport() } + } + + private fun StoredHealthReport.roundTrip() = + assertNotNullReport(toCompressedByteArray()) + + private fun assertNotNullReport(bytes: ByteArray) = + assertNotNull(bytes.toStoredHealthReport()) + + private fun reportOf( + items: List, + relations: List = emptyList(), + gaps: Map = emptyMap(), + ) = StoredHealthReport( + items = items, + relationalFindings = relations, + gaps = gaps, + breachCheckEnabled = true, + ) + + private fun item(n: Int) = protoStoredItem { + id = ByteArray(15).plus(n.toByte()).toByteString() + fingerprint = byteArrayOf(n.toByte()).toByteString() + } + + private fun versioned( + block: ProtoStoredHealthReportKt.Dsl.() -> Unit, + ) = protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + block() + }.gzip() + + private fun ProtoStoredHealthReport.gzip(): ByteArray = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(toByteArray()) } + }.toByteArray() + + private fun ByteArray.unzipProto() = ProtoStoredHealthReport.parseFrom( + GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }, + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt new file mode 100644 index 000000000..83343dc5b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState +import de.davis.keygo.feature.password_health.domain.model.BreachCheckState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class BreachCheckStateRepositoryImplTest { + + private val dataStore = FakeDataStore(ProtoBreachCheckState.getDefaultInstance()) + private val repository = BreachCheckStateRepositoryImpl(dataStore) + + @Test + fun theBreachCheckIsOffUntilTurnedOn() = runTest { + assertEquals(BreachCheckState(enabled = false), repository.getBreachCheckState()) + } + + @Test + fun turningItOnIsRemembered() = runTest { + repository.setBreachEnabled(true) + + assertEquals(BreachCheckState(enabled = true), repository.getBreachCheckState()) + assertEquals(true, dataStore.state.value.enabled) + } + + @Test + fun turningItOffAgainIsRemembered() = runTest { + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals(BreachCheckState(enabled = false), repository.getBreachCheckState()) + } + + @Test + fun observersSeeEveryChange() = runTest { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + repository.observeBreachCheckState().take(3).toList(seen) + } + + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals( + listOf(false, true, false).map { BreachCheckState(enabled = it) }, + seen, + ) + } + + @Test + fun observingStartsFromTheStoredValue() = runTest { + dataStore.state.value = ProtoBreachCheckState.newBuilder().setEnabled(true).build() + + assertEquals(BreachCheckState(enabled = true), repository.observeBreachCheckState().first()) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt new file mode 100644 index 000000000..1fa8c56a3 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachedRepositoryImplTest.kt @@ -0,0 +1,360 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.Collections +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Robolectric only so the warnings can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BreachedRepositoryImplTest { + + private val api = FakeRangeApi() + private val repository = BreachedRepositoryImpl( + OkHttpClient.Builder().addInterceptor(api).build(), + ) + + @Test + fun asksThePwnedPasswordsRangeEndpointForThePrefix() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals( + "https://api.pwnedpasswords.com/range/5BAA6", + api.requests.single().url.toString(), + ) + } + + @Test + fun asksForAPaddedUncachedAnswer() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + val request = api.requests.single() + assertEquals("true", request.header("Add-Padding")) + assertEquals("no-store", request.header("Cache-Control")) + assertEquals("GET", request.method) + } + + @Test + fun neverSendsTheSuffixes() = runTest { + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertTrue(SUFFIX !in api.requests.single().url.toString()) + } + + @Test + fun readsTheCountOfAnAskedSuffix() = runTest { + api.answer(body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\n$SUFFIX:9545824\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 9_545_824), counts) + } + + @Test + fun readsSeveralAskedSuffixesFromOneRange() = runTest { + api.answer(body = "AAA:1\nBBB:2\nCCC:3\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "CCC")).assertSuccess() + + assertEquals(mapOf("AAA" to 1, "CCC" to 3), counts) + } + + @Test + fun aSuffixMissingFromTheRangeIsClean() = runTest { + api.answer(body = "AAA:1\nBBB:2\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun matchesTheSuffixWhateverItsCaseAndKeysItAsAsked() = runTest { + api.answer(body = "${SUFFIX.lowercase()}:4\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 4), counts) + } + + @Test + fun aPaddingLineWithAZeroCountIsClean() = runTest { + api.answer(body = "$SUFFIX:0\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun aLongerSuffixSharingTheStartIsNotAMatch() = runTest { + api.answer(body = "AAAB:5\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA")).assertSuccess() + + assertEquals(emptyMap(), counts) + } + + @Test + fun malformedLinesAreSkipped() = runTest { + api.answer(body = "garbage\n:12\nAAA:many\nAAA:\n\nBBB:2\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "BBB")).assertSuccess() + + assertEquals(mapOf("BBB" to 2), counts) + } + + @Test + fun windowsLineEndingsAndPaddedCountsAreRead() = runTest { + api.answer(body = "AAA: 7 \r\nBBB:2\r\n") + + val counts = repository.occurrences(PREFIX, setOf("AAA", "BBB")).assertSuccess() + + assertEquals(mapOf("AAA" to 7, "BBB" to 2), counts) + } + + @Test + fun aRangeWithoutATrailingNewlineIsReadToTheEnd() = runTest { + api.answer(body = "AAA:1\nBBB:2") + + val counts = repository.occurrences(PREFIX, setOf("BBB")).assertSuccess() + + assertEquals(mapOf("BBB" to 2), counts) + } + + @Test + fun anEmptyRangeIsClean() = runTest { + api.answer(body = "") + + assertEquals(emptyMap(), repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess()) + } + + @Test + fun aRangeAsLongAsARealOneIsRead() = runTest { + api.answer(body = paddedRange(lines = 2_000, hit = "$SUFFIX:3")) + + assertEquals( + mapOf(SUFFIX to 3), + repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess(), + ) + } + + @Test + fun aRangeThatNeverEndsIsNotTakenAsClean() = runTest { + api.answer(body = paddedRange(lines = 20_000, hit = null)) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aRangeThatNeverEndsFailsEvenWhenTheHitCameEarly() = runTest { + api.answer(body = "$SUFFIX:3\n" + paddedRange(lines = 20_000, hit = null)) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aServerErrorIsAnApiFailure() = runTest { + api.answer(code = 500) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(1, api.requests.size) + } + + @Test + fun aClientErrorIsAnApiFailure() = runTest { + api.answer(code = 400) + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun aDroppedConnectionIsUnreachable() = runTest { + api.fail() + + assertEquals( + BreachedError.Unreachable, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(1, api.requests.size) + } + + @Test + fun aThrottledLookupIsRetried() = runTest { + api.answer(code = 429) + api.answer(body = "$SUFFIX:2\n") + + val counts = repository.occurrences(PREFIX, setOf(SUFFIX)).assertSuccess() + + assertEquals(mapOf(SUFFIX to 2), counts) + assertEquals(2, api.requests.size) + } + + @Test + fun aRetryThatFailsDifferentlyReportsThatFailure() = runTest { + api.answer(code = 429) + api.fail() + + assertEquals( + BreachedError.Unreachable, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + } + + @Test + fun givesUpAfterThreeThrottledAttempts() = runTest { + repeat(5) { api.answer(code = 429) } + + assertEquals( + BreachedError.ApiFailed, + repository.occurrences(PREFIX, setOf(SUFFIX)).assertFailure(), + ) + assertEquals(3, api.requests.size) + } + + @Test + fun waitsAsLongAsRetryAfterAsks() = runTest { + api.answer(code = 429, retryAfter = "3") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(3_000, testScheduler.currentTime) + } + + @Test + fun waitsAtLeastASecond() = runTest { + api.answer(code = 429, retryAfter = "0") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun waitsAtMostTenSeconds() = runTest { + api.answer(code = 429, retryAfter = "3600") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(10_000, testScheduler.currentTime) + } + + @Test + fun waitsASecondWithoutRetryAfter() = runTest { + api.answer(code = 429) + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun waitsASecondWhenRetryAfterIsADate() = runTest { + api.answer(code = 429, retryAfter = "Wed, 21 Oct 2015 07:28:00 GMT") + api.answer(body = "") + + repository.occurrences(PREFIX, setOf(SUFFIX)) + + assertEquals(1_000, testScheduler.currentTime) + } + + @Test + fun noSuffixesAsksNobody() = runTest { + assertEquals(emptyMap(), repository.occurrences(PREFIX, emptySet()).assertSuccess()) + assertTrue(api.requests.isEmpty()) + } + + @Test + fun aLowerCasePrefixIsAccepted() = runTest { + api.answer(body = "") + + repository.occurrences("5baa6", setOf(SUFFIX)).assertSuccess() + } + + @Test + fun aPrefixThatIsNotFiveHexCharactersIsRefusedWithoutAsking() = runTest { + listOf("", "5BAA", "5BAA61", "5BAG6", "5BA 6", "../x", "5BAA6/").forEach { prefix -> + assertEquals( + BreachedError.InvalidPrefix, + repository.occurrences(prefix, setOf(SUFFIX)).assertFailure(), + "prefix '$prefix'", + ) + } + assertTrue(api.requests.isEmpty()) + } + + private fun paddedRange(lines: Int, hit: String?) = buildString { + repeat(lines) { append("%035X:0\n".format(it)) } + hit?.let { append(it).append('\n') } + } + + private class FakeRangeApi : Interceptor { + + val requests: MutableList = Collections.synchronizedList(mutableListOf()) + private val answers = ArrayDeque<(Request) -> Response>() + + fun answer(code: Int = 200, body: String = "", retryAfter: String? = null) { + answers += { request -> + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("") + .body(body.toResponseBody()) + .apply { retryAfter?.let { header("Retry-After", it) } } + .build() + } + } + + fun fail() { + answers += { throw IOException("connection reset") } + } + + override fun intercept(chain: Interceptor.Chain): Response { + requests += chain.request() + return synchronized(answers) { answers.removeFirst() }(chain.request()) + } + } + + private companion object { + const val PREFIX = "5BAA6" + const val SUFFIX = "1E4C9B93F3F0682250B6CF8331B7EE68FD8" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt new file mode 100644 index 000000000..f6beb4535 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/FakeDataStore.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FakeDataStore(initial: T) : DataStore { + + val state = MutableStateFlow(initial) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: T) -> T): T = + transform(state.value).also { state.value = it } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt new file mode 100644 index 000000000..e6d55fc6b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt @@ -0,0 +1,228 @@ +package de.davis.keygo.feature.password_health.data.repository + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport +import de.davis.keygo.feature.password_health.data.mapper.toCompressedByteArray +import de.davis.keygo.feature.password_health.data.mapper.toStoredHealthReport +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.breach +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.storedItem +import de.davis.keygo.rust.FakeItemManager +import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager +import kotlinx.coroutines.test.runTest +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HealthReportStoreRepositoryImplTest { + + private val session = FakeSession(startUnlocked = true) + private val provider = BindingCryptographicScopeProvider( + session = session, + itemRepository = FakeItemRepository(), + itemManager = FakeItemManager(), + keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), + ) + private val dataStore = FakeDataStore(ProtoHealthReportStore.getDefaultInstance()) + private val repository = HealthReportStoreRepositoryImpl(dataStore, provider) + + private val report = StoredHealthReport( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak, breach = breach(3, CHECKED_AT)), + storedItem(id(1), breach = breach(0, CHECKED_AT)), + storedItem(id(2), unreadable = true), + ), + relationalFindings = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(2)))), + breachCheckEnabled = true, + ) + + @Test + fun nothingStoredYetIsNoReport() = runTest { + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aStoredReportLoadsBackUnchanged() = runTest { + repository.storeReport(report).assertSuccess() + + assertEquals(report, repository.load().assertSuccess()) + } + + @Test + fun theReportIsNotStoredInTheClear() = runTest { + repository.storeReport(report).assertSuccess() + + val stored = dataStore.state.value + assertTrue(stored.wrappedKey.size() > 0) + assertTrue(stored.iv.size() > 0) + assertTrue( + !stored.ciphertext.toByteArray().contentEquals(report.toCompressedByteArray()), + ) + } + + @Test + fun theLatestReportWins() = runTest { + val newer = report.copy(breachCheckEnabled = false, gaps = emptyMap()) + + repository.storeReport(report).assertSuccess() + repository.storeReport(newer).assertSuccess() + + assertEquals(newer, repository.load().assertSuccess()) + } + + @Test + fun laterReportsReuseTheFirstKey() = runTest { + repository.storeReport(report).assertSuccess() + val firstKey = dataStore.state.value.wrappedKey + + repository.storeReport(report.copy(breachCheckEnabled = false)).assertSuccess() + + assertEquals(firstKey, dataStore.state.value.wrappedKey) + } + + @Test + fun aLockedSessionCannotStoreAndLeavesTheStoreUntouched() = runTest { + val locked = HealthReportStoreRepositoryImpl( + dataStore, + BindingCryptographicScopeProvider( + session = FakeSession(startUnlocked = false), + itemRepository = FakeItemRepository(), + itemManager = FakeItemManager(), + keyWrapper = FakeKeyWrapper(), + vaultManager = FakeVaultManager(), + ), + ) + + assertEquals(HealthReportStoreError.CryptoFailed, locked.storeReport(report).assertFailure()) + assertEquals(ProtoHealthReportStore.getDefaultInstance(), dataStore.state.value) + } + + @Test + fun aKeyThatWillNotUnwrapIsACryptoFailureOnStore() = runTest { + repository.storeReport(report).assertSuccess() + val before = dataStore.state.value + session.unwrapVaultKeyFailure = SessionError.Locked + + assertEquals(HealthReportStoreError.CryptoFailed, repository.storeReport(report).assertFailure()) + assertEquals(before, dataStore.state.value) + } + + @Test + fun aKeyThatWillNotUnwrapIsACryptoFailureOnLoad() = runTest { + repository.storeReport(report).assertSuccess() + session.unwrapVaultKeyFailure = SessionError.Locked + + assertEquals(HealthReportStoreError.CryptoFailed, repository.load().assertFailure()) + } + + @Test + fun aKeyWithoutAReportIsNoReport() = runTest { + repository.storeReport(report).assertSuccess() + dataStore.state.value = dataStore.state.value.toBuilder().clearCiphertext().build() + + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aReportWithoutAKeyIsNoReport() = runTest { + repository.storeReport(report).assertSuccess() + dataStore.state.value = dataStore.state.value.toBuilder().clearWrappedKey().build() + + assertEquals(HealthReportStoreError.NoReportStored, repository.load().assertFailure()) + } + + @Test + fun aReportFromAnotherAlgorithmVersionIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal( + protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + 1 + }.toByteArray().gzip(), + ) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun theReportIsSealedUnderItsFixedNamespaceAndLabel() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + + val opened = provider.accountScope( + namespace = REPORT_NAMESPACE, + wrapped = stored.wrappedKey(), + ) { + CryptographicData(stored.ciphertext.toByteArray(), stored.iv.toByteArray()) + .decrypt(REPORT_LABEL) + } + + assertEquals(report, (opened as Result.Success).success.toStoredHealthReport()) + } + + @Test + fun theKeyIsBoundToTheReportNamespace() = runTest { + repository.storeReport(report).assertSuccess() + + val opened = provider.accountScope( + namespace = UUID.randomUUID(), + wrapped = dataStore.state.value.wrappedKey(), + ) { } + + assertTrue(opened is Result.Failure) + } + + /** Replaces the stored report with [plaintext], sealed the way the repository seals one. */ + private suspend fun seal(plaintext: ByteArray) { + val stored = dataStore.state.value + val sealed = provider.accountScope(REPORT_NAMESPACE, stored.wrappedKey()) { + plaintext.encrypt(REPORT_LABEL) + }.assertSuccess() + + dataStore.state.value = stored.toBuilder() + .setCiphertext(sealed.data.toByteString()) + .setIv(sealed.iv.toByteString()) + .build() + } + + private fun ProtoHealthReportStore.wrappedKey() = KeyInformation( + wrappedKey = wrappedKey.toByteArray(), + keyNonce = wrappedKeyNonce.toByteArray(), + ) + + private fun ByteArray.gzip(): ByteArray = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(this) } + }.toByteArray() + + private companion object { + val CHECKED_AT: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000) + + // Pinned on purpose: changing either makes every stored report undecryptable. + val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") + const val REPORT_LABEL = "password_health_report" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt new file mode 100644 index 000000000..b34a31459 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt @@ -0,0 +1,198 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// Robolectric only so the gap warnings can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BreachedPasswordCheckTest { + + private val breached = FakeBreachedRepository() + private val state = FakeBreachCheckStateRepository(enabled = true) + private val checker = BreachedPasswordCheck(breached, state) + + @Test + fun reportsAsTheBreachCheck() { + assertEquals(CheckKind.Breach, checker.type) + } + + @Test + fun asksForTheRangeOfTheUpperCaseSha1() = runTest { + checker.check(candidates("password")) + + assertEquals( + listOf(FakeBreachedRepository.Call("5BAA6", setOf("1E4C9B93F3F0682250B6CF8331B7EE68FD8"))), + breached.calls, + ) + } + + @Test + fun hashesThePasswordAsUtf8() = runTest { + checker.check(candidates("pässwörd")) + + assertEquals( + FakeBreachedRepository.Call("F517D", setOf("DF1D32A112FF1AD55C66D1B12CB38E7E8F7")), + breached.calls.single(), + ) + } + + @Test + fun aBreachedPasswordIsFlaggedWithItsOccurrences() = runTest { + breached.breaches = mapOf(PASSWORD to 42) + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals( + listOf(HealthFinding.Item(id(0), ItemIssue.Breached(occurrences = 42))), + outcome.findings, + ) + assertNull(outcome.gap) + } + + @Test + fun aCleanVaultHasNoFindingsAndNoGap() = runTest { + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + @Test + fun everyLoginSharingABreachedPasswordIsFlagged() = runTest { + breached.breaches = mapOf(PASSWORD to 7) + + val outcome = checker.check(candidates("password", "password")) + + assertEquals( + setOf( + HealthFinding.Item(id(0), ItemIssue.Breached(7)), + HealthFinding.Item(id(1), ItemIssue.Breached(7)), + ), + outcome.findings.toSet(), + ) + } + + @Test + fun aSharedPasswordIsLookedUpOnce() = runTest { + checker.check(candidates("password", "password", "password")) + + assertEquals(1, breached.calls.size) + assertEquals(1, breached.calls.single().suffixes.size) + } + + @Test + fun eachRangeIsAskedForSeparately() = runTest { + checker.check(candidates("password", "hunter2")) + + assertEquals(setOf("5BAA6", "F3BBB"), breached.calls.mapTo(mutableSetOf()) { it.prefix }) + } + + @Test + fun nothingToCheckAsksNobody() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(breached.calls.isEmpty()) + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + @Test + fun aDisabledCheckSkipsEveryPasswordWithoutAsking() = runTest { + state.setBreachEnabled(false) + + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(breached.calls.isEmpty()) + assertTrue(outcome.findings.isEmpty()) + assertEquals(CheckGap(GapReason.Disabled, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun anUnreachableRangeLeavesItsPasswordsUnchecked() = runTest { + breached.failures["5BAA6"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), outcome.gap) + } + + @Test + fun aFailedApiAnswerIsAFailedGap() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + + val outcome = checker.check(candidates("password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0))), outcome.gap) + } + + @Test + fun anInvalidPrefixIsAFailedGap() = runTest { + breached.failures["5BAA6"] = BreachedError.InvalidPrefix + + val outcome = checker.check(candidates("password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0))), outcome.gap) + } + + @Test + fun unreachableWinsOverAFailureInTheSameRun() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + breached.failures["F3BBB"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun aFailedRangeKeepsTheFindingsOfTheRangesThatAnswered() = runTest { + breached.breaches = mapOf(HUNTER2 to 3) + breached.failures["5BAA6"] = BreachedError.Unreachable + + val outcome = checker.check(candidates("password", "hunter2")) + + assertEquals(listOf(HealthFinding.Item(id(1), ItemIssue.Breached(3))), outcome.findings) + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), outcome.gap) + } + + @Test + fun aFailedRangeLeavesEveryLoginSharingItsPasswordUnchecked() = runTest { + breached.failures["5BAA6"] = BreachedError.ApiFailed + + val outcome = checker.check(candidates("password", "password")) + + assertEquals(CheckGap(GapReason.Failed, setOf(id(0), id(1))), outcome.gap) + } + + @Test + fun thePasswordsAreLeftForTheCallerToWipe() = runTest { + val candidates = candidates("password") + + checker.check(candidates) + + assertEquals("password", candidates.single().password.concatToString()) + } + + private fun id(n: Int) = UUID(0L, n.toLong()) + + private companion object { + const val PASSWORD = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" + const val HUNTER2 = "F3BBBD66A63D4BF1747940578EC3D0103530E21D" + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt new file mode 100644 index 000000000..d7dab9954 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/ReusePasswordCheckTest.kt @@ -0,0 +1,94 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.RelationType +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReusePasswordCheckTest { + + private val checker = ReusePasswordCheck() + + @Test + fun reportsAsTheReuseCheck() { + assertEquals(CheckKind.Reuse, checker.type) + } + + @Test + fun flagsTwoLoginsSharingAPassword() = runTest { + assertEquals(setOf(setOf("hunter2")), reused("hunter2", "hunter2", "other-password")) + } + + @Test + fun everyLoginSharingThePasswordIsInTheOneFinding() = runTest { + val candidates = candidates("same", "same", "same") + + val findings = ReusePasswordCheck().check(candidates).findings + + assertEquals( + listOf( + HealthFinding.Relation( + relatedItemIds = candidates.mapTo(mutableSetOf()) { it.id }, + type = RelationType.Reused, + ), + ), + findings, + ) + } + + @Test + fun separateSharedPasswordsAreSeparateFindings() = runTest { + assertEquals(setOf(setOf("alpha"), setOf("beta")), reused("alpha", "beta", "alpha", "beta")) + } + + @Test + fun uniquePasswordsAreNotReused() = runTest { + assertTrue(reused("one", "two", "three").isEmpty()) + } + + @Test + fun passwordsDifferingOnlyInCaseAreNotReused() = runTest { + assertTrue(reused("Password", "password").isEmpty()) + } + + @Test + fun passwordsDifferingOnlyInTrailingWhitespaceAreNotReused() = runTest { + assertTrue(reused("password", "password ").isEmpty()) + } + + @Test + fun sharedNonAsciiPasswordsAreReused() = runTest { + assertEquals(setOf(setOf("pässwörd🔑")), reused("pässwörd🔑", "pässwörd🔑")) + } + + @Test + fun aSinglePasswordIsNotReused() = runTest { + assertTrue(reused("lonely").isEmpty()) + } + + @Test + fun nothingToCheckFindsNothing() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + /** The reused groups, as the passwords themselves collapsed to one per group. */ + private suspend fun reused(vararg passwords: String): Set> { + val candidates = candidates(*passwords) + val byId = candidates.associate { it.id to it.password.concatToString() } + + return checker.check(candidates).findings + .map { assertRelation(it) } + .mapTo(mutableSetOf()) { ids -> ids.mapTo(mutableSetOf()) { byId.getValue(it) } } + } + + private fun assertRelation(finding: HealthFinding) = + (finding as HealthFinding.Relation).also { assertEquals(RelationType.Reused, it.type) } + .relatedItemIds +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt new file mode 100644 index 000000000..9d9751a3b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/WeakPasswordCheckerTest.kt @@ -0,0 +1,91 @@ +package de.davis.keygo.feature.password_health.domain.checker + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WeakPasswordCheckerTest { + + private val checker = WeakPasswordChecker() + + @Test + fun reportsAsTheStrengthCheck() { + assertEquals(CheckKind.Strength, checker.type) + } + + @Test + fun flagsRidiculousAndWeakScores() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Ridiculous), + candidate(1, PasswordScore.Weak), + ), + ) + + assertEquals( + listOf( + HealthFinding.Item(id(0), ItemIssue.Weak(PasswordScore.Ridiculous)), + HealthFinding.Item(id(1), ItemIssue.Weak(PasswordScore.Weak)), + ), + outcome.findings, + ) + assertNull(outcome.gap) + } + + @Test + fun leavesModerateAndStrongerScoresAlone() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Moderate), + candidate(1, PasswordScore.Strong), + candidate(2, PasswordScore.Excellent), + ), + ) + + assertTrue(outcome.findings.isEmpty()) + } + + @Test + fun anUnscoredPasswordIsNotCalledWeak() = runTest { + val outcome = checker.check(listOf(candidate(0, PasswordScore.None))) + + assertTrue(outcome.findings.isEmpty()) + } + + @Test + fun onlyTheWeakOnesOfAMixedVaultAreFlagged() = runTest { + val outcome = checker.check( + listOf( + candidate(0, PasswordScore.Strong), + candidate(1, PasswordScore.Weak), + candidate(2, PasswordScore.Excellent), + ), + ) + + assertEquals( + listOf(id(1)), + outcome.findings.map { (it as HealthFinding.Item).id }, + ) + } + + @Test + fun nothingToCheckFindsNothing() = runTest { + val outcome = checker.check(emptyList()) + + assertTrue(outcome.findings.isEmpty()) + assertNull(outcome.gap) + } + + private fun id(n: Int) = UUID(0L, n.toLong()) + + private fun candidate(n: Int, score: PasswordScore) = + PasswordCandidate(id = id(n), score = score, password = "pw-$n".toCharArray()) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt new file mode 100644 index 000000000..262b8c74b --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/ItemHealthTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ItemHealthTest { + + @Test + fun anItemWithoutIssuesHasNoSeverity() { + val health = health() + + assertNull(health.maxSeverity) + assertNull(health.breach) + assertNull(health.weak) + } + + @Test + fun aWeakPasswordIsMedium() { + val health = health(ItemIssue.Weak(PasswordScore.Weak)) + + assertEquals(FindingSeverity.Medium, health.maxSeverity) + assertEquals(ItemIssue.Weak(PasswordScore.Weak), health.weak) + assertNull(health.breach) + } + + @Test + fun aBreachedPasswordIsCritical() { + val health = health(ItemIssue.Breached(5)) + + assertEquals(FindingSeverity.Critical, health.maxSeverity) + assertEquals(ItemIssue.Breached(5), health.breach) + assertNull(health.weak) + } + + @Test + fun theGravestIssueSetsTheSeverity() { + val health = health(ItemIssue.Weak(PasswordScore.Ridiculous), ItemIssue.Breached(1)) + + assertEquals(FindingSeverity.Critical, health.maxSeverity) + assertEquals(ItemIssue.Breached(1), health.breach) + assertEquals(ItemIssue.Weak(PasswordScore.Ridiculous), health.weak) + } + + @Test + fun severitiesRankMediumBelowHighBelowCritical() { + assertEquals( + listOf(FindingSeverity.Medium, FindingSeverity.High, FindingSeverity.Critical), + FindingSeverity.entries.sorted(), + ) + } + + @Test + fun reuseIsGraverThanSimilarity() { + assertEquals(FindingSeverity.High, RelationType.Reused.severity) + assertEquals(FindingSeverity.Medium, RelationType.Similar.severity) + } + + @Test + fun aSkippedCheckLeavesEveryCandidateUnchecked() { + val candidates = List(3) { + PasswordCandidate(UUID(0L, it.toLong()), PasswordScore.Strong, charArrayOf('x')) + } + + val outcome = CheckOutcome.skipped(GapReason.Disabled, candidates) + + assertEquals(emptyList(), outcome.findings) + assertEquals( + CheckGap(GapReason.Disabled, candidates.mapTo(mutableSetOf()) { it.id }), + outcome.gap, + ) + } + + private fun health(vararg issues: ItemIssue) = ItemHealth( + itemId = UUID(0L, 0L), + title = "item", + username = null, + issues = issues.toList(), + ) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt index 97b0780e9..673bdb8d9 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/RelatedGroupTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.password_health.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -55,6 +56,54 @@ class RelatedGroupTest { assertEquals(FindingSeverity.High, group.maxSeverity) } + @Test + fun aBreachedMemberMakesTheGroupCritical() { + val group = RelatedGroup( + id = ids[0], + members = setOf(item(ids[0], ItemIssue.Breached(1)), item(ids[1])), + relations = setOf(relation(RelationType.Similar, ids[0], ids[1])), + ) + + assertEquals(FindingSeverity.Critical, group.maxSeverity) + } + + @Test + fun aGroupWithoutMemberIssuesTakesTheSeverityOfItsRelation() { + val group = RelatedGroup( + id = ids[0], + members = setOf(item(ids[0]), item(ids[1])), + relations = setOf(relation(RelationType.Similar, ids[0], ids[1])), + ) + + assertEquals(FindingSeverity.Medium, group.maxSeverity) + } + + @Test + fun ordersTheGravestMemberFirstAndTheCleanOnesLast() { + val group = RelatedGroup( + id = ids[0], + members = setOf( + item(ids[0]), + item(ids[1], ItemIssue.Weak(PasswordScore.Weak)), + item(ids[2], ItemIssue.Breached(9)), + ), + relations = setOf(relation(RelationType.Reused, ids[0], ids[1], ids[2])), + ) + + assertEquals(listOf(ids[2], ids[1], ids[0]), group.orderedMembers.map { it.itemId }) + } + + @Test + fun aSingleRelationNamesTheGroup() { + assertEquals( + RelationType.Similar, + group(relation(RelationType.Similar, ids[0], ids[1])).dominantRelation, + ) + } + + private fun item(id: ItemId, vararg issues: ItemIssue) = + ItemHealth(itemId = id, title = "item", username = null, issues = issues.toList()) + private fun group(vararg relations: HealthFinding.Relation) = RelatedGroup( id = ids.first(), members = ids.mapTo(mutableSetOf()) { diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt index ff2e34e80..a41320a58 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReportTest.kt @@ -9,6 +9,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -111,4 +112,100 @@ class StoredHealthReportTest { mixed.breachResultsAt(current, checkedAt) ) } + + @Test + fun aBreachLookupHoldsUntilJustBeforeItsTtl() { + val result = breach(0, checkedAt) + + assertTrue(result.isCurrentAt(checkedAt + BreachResult.TTL - 1.nanoseconds)) + assertFalse(result.isCurrentAt(checkedAt + BreachResult.TTL)) + } + + @Test + fun aBreachLookupFromTheFutureStillHolds() { + assertTrue(breach(0, checkedAt).isCurrentAt(checkedAt - 1.days)) + } + + @Test + fun isStaleWhileAPasswordWasNeverLookedUp() { + val unchecked = report.copy(items = listOf(storedItem(ids[0]), report.items[1])) + + assertFalse(unchecked.isFreshFor(fingerprints, true, checkedAt)) + } + + @Test + fun isStaleWhenALoginWasRemoved() { + assertFalse(report.isCurrentFor(mapOf(ids[0] to fingerprint(ids[0])), true)) + } + + @Test + fun isStaleWhenTheBreachCheckWasTurnedOn() { + val disabled = report.copy(items = ids.map { storedItem(it) }, breachCheckEnabled = false) + + assertFalse(disabled.isCurrentFor(fingerprints, true)) + } + + @Test + fun aDisabledCheckIsNoReasonToRescan() { + val skipped = report.copy( + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Disabled, setOf(ids[0]))), + ) + + assertTrue(skipped.isCurrentFor(fingerprints, true)) + } + + @Test + fun isStaleWhenAnotherCheckCouldNotReachItsService() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Strength to CheckGap(GapReason.Unreachable, setOf(ids[0]))), + ) + + assertFalse(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun aFailedBreachLookupIsRetriedWithoutTheOtherChecks() { + val incomplete = report.copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(ids[0]))), + ) + + assertTrue(incomplete.isCurrentFor(fingerprints, true)) + } + + @Test + fun anEmptyReportIsCurrentForAnEmptyVault() { + val empty = report.copy(items = emptyList()) + + assertTrue(empty.isFreshFor(emptyMap(), true, checkedAt)) + } + + @Test + fun breachResultsSkipItemsThatWereNeverLookedUp() { + val unchecked = report.copy(items = listOf(storedItem(ids[0]))) + + assertEquals(emptyMap(), unchecked.breachResultsAt(fingerprints, checkedAt)) + } + + @Test + fun breachResultsSkipLoginsThatAreGone() { + assertEquals( + mapOf(ids[1] to breach(0, checkedAt)), + report.breachResultsAt(mapOf(ids[1] to fingerprint(ids[1])), checkedAt), + ) + } + + @Test + fun countsEveryItemAsScanned() { + assertEquals(2, report.totalPasswordsScanned) + } + + @Test + fun fingerprintsCompareByContent() { + assertEquals(HealthFingerprint(byteArrayOf(1, 2)), HealthFingerprint(byteArrayOf(1, 2))) + assertEquals( + HealthFingerprint(byteArrayOf(1, 2)).hashCode(), + HealthFingerprint(byteArrayOf(1, 2)).hashCode(), + ) + assertFalse(HealthFingerprint(byteArrayOf(1, 2)) == HealthFingerprint(byteArrayOf(2, 1))) + } } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt index 696c0b177..26c881f3e 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportAssemblerTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.password_health.domain.report +import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind @@ -11,6 +12,7 @@ import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class HealthReportAssemblerTest { @@ -107,6 +109,160 @@ class HealthReportAssemblerTest { ) } + @Test + fun itemsCarryTheLoginTheyDescribe() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.Weak))) + val login = login(id(0)).copy( + name = "Mail", + username = "me@example.com", + domainInfos = setOf(DomainInfo(value = "mail.example.com", eTLD1 = "example.com")), + ) + + val item = assembler.assemble(report, listOf(login)).standalone.single() + + assertEquals("Mail", item.title) + assertEquals("me@example.com", item.username) + assertEquals(listOf("mail.example.com"), item.urls) + } + + @Test + fun aWeakBreachedPasswordListsTheBreachFirst() { + val report = report( + items = listOf(storedItem(id(0), score = PasswordScore.Ridiculous, breach = breach(2))), + ) + + assertEquals( + listOf(ItemIssue.Breached(2), ItemIssue.Weak(PasswordScore.Ridiculous)), + assembler.assemble(report, logins(0..0)).standalone.single().issues, + ) + } + + @Test + fun aCleanBreachLookupIsNoIssue() { + val report = report(items = listOf(storedItem(id(0), breach = breach(0)))) + + assertEquals(emptyList(), assembler.assemble(report, logins(0..0)).standalone) + } + + @Test + fun aNoneScoreIsNoIssue() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.None))) + + assertEquals(emptyList(), assembler.assemble(report, logins(0..0)).standalone) + } + + @Test + fun aRelatedItemIsInItsGroupNotStandalone() { + val report = report( + items = listOf(storedItem(id(0), breach = breach(1)), storedItem(id(1))), + relations = listOf(relation(RelationType.Reused, 0, 1)), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(emptyList(), assembled.standalone) + assertEquals( + listOf(ItemIssue.Breached(1)), + assembled.groups.single().members.single { it.itemId == id(0) }.issues, + ) + } + + @Test + fun aGroupKeepsItsCleanMembers() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + relations = listOf(relation(RelationType.Similar, 0, 1)), + ) + + val group = assembler.assemble(report, logins(0..1)).groups.single() + + assertEquals(setOf(id(0), id(1)), group.members.mapTo(mutableSetOf()) { it.itemId }) + assertTrue(group.id in setOf(id(0), id(1))) + } + + @Test + fun separateRelationsFormSeparateGroupsGravestFirst() { + val report = report( + items = (0..5).map { storedItem(id(it)) }.toMutableList().apply { + set(4, storedItem(id(4), breach = breach(1))) + }, + relations = listOf( + relation(RelationType.Similar, 0, 1), + relation(RelationType.Reused, 2, 3), + relation(RelationType.Similar, 4, 5), + ), + ) + + val groups = assembler.assemble(report, logins(0..5)).groups + + assertEquals( + listOf(setOf(id(4), id(5)), setOf(id(2), id(3)), setOf(id(0), id(1))), + groups.map { group -> group.members.mapTo(mutableSetOf()) { it.itemId } }, + ) + } + + @Test + fun standaloneItemsAreGravestFirst() { + val report = report( + items = listOf( + storedItem(id(0), score = PasswordScore.Weak), + storedItem(id(1), breach = breach(4)), + storedItem(id(2), score = PasswordScore.Ridiculous), + ), + ) + + assertEquals( + id(1), + assembler.assemble(report, logins(0..2)).standalone.first().itemId, + ) + } + + @Test + fun aLoginAddedSinceTheScanIsLeftOut() { + val report = report(items = listOf(storedItem(id(0), score = PasswordScore.Weak))) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(listOf(id(0)), assembled.standalone.map { it.itemId }) + assertEquals(1, assembled.totalPasswordsScanned) + } + + @Test + fun gapsAndUnreadableItemsAreCarriedOver() { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0), id(1))) + val report = report( + items = listOf(storedItem(id(0), unreadable = true), storedItem(id(1))), + gaps = mapOf(CheckKind.Breach to gap), + ) + + val assembled = assembler.assemble(report, logins(0..1)) + + assertEquals(mapOf(CheckKind.Breach to gap), assembled.gaps) + assertEquals(setOf(id(0)), assembled.unreadable) + } + + @Test + fun aGapLeftWithoutLoginsIsDropped() { + val report = report( + items = listOf(storedItem(id(0)), storedItem(id(1))), + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Failed, setOf(id(0)))), + ) + + assertEquals(emptyMap(), assembler.assemble(report, logins(1..1)).gaps) + } + + @Test + fun aCleanVaultAssemblesToNothing() { + val report = report(items = (0..2).map { storedItem(id(it)) }) + + val assembled = assembler.assemble(report, logins(0..2)) + + assertEquals(emptyList(), assembled.groups) + assertEquals(emptyList(), assembled.standalone) + assertEquals(emptySet(), assembled.unreadable) + assertEquals(3, assembled.totalPasswordsScanned) + } + private fun logins(range: IntRange) = range.map { login(id(it)) } private fun relation(type: RelationType, vararg ids: Int) = HealthFinding.Relation( diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt index b8e23a4fb..ed3aeb113 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/HealthReportScannerTest.kt @@ -195,11 +195,239 @@ class HealthReportScannerTest { @Test fun nothingToCheckIsNoPasswords() = runTest { - val result = scan(listOf(login(id(0))), RecordingChecker(CheckKind.Strength)) + val result = scan( + listOf(login(id(0)).copy(passwordCredential = null)), + RecordingChecker(CheckKind.Strength), + ) + + assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) + } + + @Test + fun aVaultWhosePasswordsAllFailToDecryptIsUnreadable() = runTest { + val result = scan(listOf(login(id(0)), login(id(1))), RecordingChecker(CheckKind.Strength)) + + assertEquals(Result.Failure(PasswordHealthReportError.Unreadable), result) + } + + @Test + fun anEmptyVaultIsNoPasswords() = runTest { + val result = scan(emptyList(), RecordingChecker(CheckKind.Strength)) assertEquals(Result.Failure(PasswordHealthReportError.NoPasswords), result) } + @Test + fun aLoginWithoutAPasswordIsNeitherCheckedNorUnreadable() = runTest { + val checker = RecordingChecker(CheckKind.Strength) + val logins = readable(0) + login(id(1)).copy(passwordCredential = null) + + val report = scan(logins, checker).success() + + assertEquals(setOf(id(0)), checker.seen.keys) + assertEquals(listOf(false, false), report.items.map { it.unreadable }) + } + + @Test + fun aLoginWithoutAFingerprintIsLeftOutOfTheReport() = runTest { + val logins = readable(0, 1) + + val report = HealthReportScanner(scopeProvider, listOf(RecordingChecker(CheckKind.Strength))) + .scan( + logins = logins, + fingerprints = mapOf(id(0) to fingerprint(id(0))), + previous = null, + breachCheckEnabled = true, + now = NOW, + ).success() + + assertEquals(listOf(id(0)), report.items.map { it.id }) + } + + @Test + fun theReportRemembersWhetherTheBreachCheckWasOn() = runTest { + val on = scan(readable(0), RecordingChecker(CheckKind.Strength)).success() + val off = scan( + readable(0), + RecordingChecker(CheckKind.Strength), + breachCheckEnabled = false, + ).success() + + assertEquals(true, on.breachCheckEnabled) + assertEquals(false, off.breachCheckEnabled) + } + + @Test + fun withoutABreachCheckerNoPasswordGetsABreachResult() = runTest { + val report = scan(readable(0, 1), RecordingChecker(CheckKind.Strength)).success() + + assertEquals(listOf(null, null), report.items.map { it.breach }) + } + + @Test + fun everyCheckerSeesEveryPasswordOnAFreshScan() = runTest { + val checkers = listOf( + RecordingChecker(CheckKind.Strength), + RecordingChecker(CheckKind.Reuse), + RecordingChecker(CheckKind.Similarity), + RecordingChecker(CheckKind.Breach), + ) + + scan(readable(0, 1, 2), *checkers.toTypedArray()) + + checkers.forEach { assertEquals(setOf(id(0), id(1), id(2)), it.seen.keys, "${it.type}") } + } + + @Test + fun gapsOfSeveralChecksAreAllKept() = runTest { + val breachGap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val similarityGap = CheckGap(GapReason.Failed, setOf(id(0), id(1))) + + val report = scan( + readable(0, 1), + RecordingChecker(CheckKind.Breach, CheckOutcome(gap = breachGap)), + RecordingChecker(CheckKind.Similarity, CheckOutcome(gap = similarityGap)), + ).success() + + assertEquals( + mapOf(CheckKind.Breach to breachGap, CheckKind.Similarity to similarityGap), + report.gaps, + ) + } + + @Test + fun aFullyCurrentReportIsRefreshedWithoutDecryptingAnything() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val breachCheck = RecordingChecker(CheckKind.Breach) + val previous = report( + storedItem(id(0), breach = breach(0, NOW)), + storedItem(id(1), breach = breach(2, NOW)), + ) + + val report = scan(readable(0, 1), strength, breachCheck, previous = previous).success() + + assertTrue(strength.candidates.isEmpty()) + assertTrue(breachCheck.seen.isEmpty()) + assertEquals(previous, report) + } + + @Test + fun aRefreshReplacesTheOldBreachGapAndKeepsTheRest() = runTest { + val disabledSimilarity = CheckGap(GapReason.Disabled, setOf(id(0))) + val newGap = CheckGap(GapReason.Failed, setOf(id(0))) + val previous = report(storedItem(id(0))).copy( + gaps = mapOf( + CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0))), + CheckKind.Similarity to disabledSimilarity, + ), + ) + + val report = scan( + readable(0), + RecordingChecker(CheckKind.Breach, CheckOutcome(gap = newGap)), + previous = previous, + ).success() + + assertEquals( + mapOf(CheckKind.Breach to newGap, CheckKind.Similarity to disabledSimilarity), + report.gaps, + ) + assertEquals(listOf(null), report.items.map { it.breach }) + } + + @Test + fun aRefreshThatReachesTheServiceClearsTheOldBreachGap() = runTest { + val previous = report(storedItem(id(0))).copy( + gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(0)))), + ) + + val report = scan( + readable(0), + RecordingChecker(CheckKind.Breach), + previous = previous, + ).success() + + assertEquals(emptyMap(), report.gaps) + assertEquals(listOf(breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun aPasswordThatStopsDecryptingDuringARefreshIsMarkedUnreadable() = runTest { + val previous = report( + storedItem(id(0), breach = breach(0, NOW)), + storedItem(id(1), breach = breach(0, NOW - BreachResult.TTL)), + ) + val logins = readable(0) + login(id(1)) + + val report = scan( + logins, + RecordingChecker(CheckKind.Breach), + previous = previous, + ).success() + + assertEquals(listOf(false, true), report.items.map { it.unreadable }) + assertEquals(listOf(breach(0, NOW), null), report.items.map { it.breach }) + } + + @Test + fun aPreviouslyUnreadablePasswordForcesAFullScan() = runTest { + val strength = RecordingChecker(CheckKind.Strength) + val previous = report(storedItem(id(0), unreadable = true, breach = breach(0, NOW))) + + val report = scan(readable(0), strength, previous = previous).success() + + assertEquals(setOf(id(0)), strength.seen.keys) + assertEquals(listOf(false), report.items.map { it.unreadable }) + } + + @Test + fun aFailedLocalCheckForcesAFullScan() = runTest { + val similarity = RecordingChecker(CheckKind.Similarity) + val previous = report(storedItem(id(0), breach = breach(0, NOW))).copy( + gaps = mapOf(CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(0)))), + ) + + val report = scan(readable(0), similarity, previous = previous).success() + + assertEquals(setOf(id(0)), similarity.seen.keys) + assertEquals(emptyMap(), report.gaps) + } + + @Test + fun keptBreachLookupsAreNotRepeatedOnAFullScan() = runTest { + val breachCheck = RecordingChecker( + CheckKind.Breach, + CheckOutcome(findings = listOf(HealthFinding.Item(id(0), ItemIssue.Breached(99)))), + ) + val previous = report(storedItem(id(0), breach = breach(5, NOW))) + + val report = scan( + readable(0, 1), + breachCheck, + previous = previous, + ).success() + + assertEquals(setOf(id(1)), breachCheck.seen.keys) + assertEquals(listOf(breach(5, NOW), breach(0, NOW)), report.items.map { it.breach }) + } + + @Test + fun passwordsAreWipedEvenWhenACheckerFails() = runTest { + val failing = object : PasswordHealthChecker { + override val type = CheckKind.Similarity + var seen: List = emptyList() + + override suspend fun check(candidates: List): CheckOutcome { + seen = candidates + error("boom") + } + } + + runCatching { scan(readable(0), failing) } + + assertTrue(failing.seen.single().password.all { it == '\u0000' }) + } + private suspend fun scan( logins: List, vararg checkers: PasswordHealthChecker, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt new file mode 100644 index 000000000..0f3876c06 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/report/UnionFindTest.kt @@ -0,0 +1,76 @@ +package de.davis.keygo.feature.password_health.domain.report + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class UnionFindTest { + + private val uf = UnionFind() + + @Test + fun anUnseenElementIsNotContained() { + assertFalse(uf.contains(1)) + } + + @Test + fun findingAnElementAddsItAsItsOwnRoot() { + assertEquals(1, uf.find(1)) + assertTrue(uf.contains(1)) + } + + @Test + fun unitedElementsShareARoot() { + uf.union(1, 2) + + assertEquals(uf.find(1), uf.find(2)) + assertTrue(uf.contains(1)) + assertTrue(uf.contains(2)) + } + + @Test + fun unionIsTransitive() { + uf.union(1, 2) + uf.union(3, 4) + uf.union(2, 3) + + val root = uf.find(1) + assertEquals(listOf(root, root, root), listOf(2, 3, 4).map(uf::find)) + } + + @Test + fun separateSetsKeepSeparateRoots() { + uf.union(1, 2) + uf.union(3, 4) + + assertNotEquals(uf.find(1), uf.find(3)) + } + + @Test + fun unitingAnElementWithItselfChangesNothing() { + uf.union(1, 1) + + assertEquals(1, uf.find(1)) + } + + @Test + fun unitingTheSameSetTwiceChangesNothing() { + uf.union(1, 2) + val root = uf.find(1) + + uf.union(2, 1) + + assertEquals(root, uf.find(1)) + assertEquals(root, uf.find(2)) + } + + @Test + fun aLongChainStillResolvesToOneRoot() { + (0 until 1_000).zipWithNext().forEach { (a, b) -> uf.union(a, b) } + + val root = uf.find(0) + assertTrue((0 until 1_000).all { uf.find(it) == root }) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt new file mode 100644 index 000000000..5222afcd1 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -0,0 +1,351 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.BreachResult +import de.davis.keygo.feature.password_health.domain.model.BreachedError +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelationType +import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.security.MessageDigest +import java.util.Collections +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +// Robolectric only so a failed store write can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PasswordHealthReportUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + private val fingerprinter = LoginFingerprinterImpl() + private val store = FakeHealthReportStore() + private val breachState = FakeBreachCheckStateRepository(enabled = true) + private val breached = FakeBreachedRepository() + + private val strength = Counting(WeakPasswordChecker()) + private val reuse = Counting(ReusePasswordCheck()) + private val breach = Counting(BreachedPasswordCheck(breached, breachState)) + + private val useCase = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = fingerprinter, + healthReportStoreRepository = store, + breachCheckStateRepository = breachState, + scanner = HealthReportScanner(scopeProvider, listOf(strength, reuse, breach)), + assembler = HealthReportAssembler(), + ) + + @Test + fun anEmptyVaultIsNoPasswords() = runTest { + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + } + + @Test + fun aVaultWithoutPasswordsIsNoPasswords() = runTest { + loginRepository.seed(login(id(0)).copy(passwordCredential = null)) + + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + assertTrue(strength.calls.isEmpty()) + } + + @Test + fun loginsWithoutAPasswordAreLeftOut() = runTest { + seed(0) + loginRepository.seed(login(id(1)).copy(passwordCredential = null)) + + val report = run().assertSuccess() + + assertEquals(1, report.totalPasswordsScanned) + assertEquals(listOf(setOf(id(0))), strength.calls) + } + + @Test + fun aFirstRunScansEveryPasswordAndStoresTheResult() = runTest { + seed(0, 1, score = PasswordScore.Weak) + + val report = run().assertSuccess() + + assertEquals(2, report.totalPasswordsScanned) + assertEquals(setOf(id(0), id(1)), report.standalone.mapTo(mutableSetOf()) { it.itemId }) + assertEquals( + listOf(ItemIssue.Weak(PasswordScore.Weak)), + report.standalone.first().issues, + ) + assertEquals(2, assertNotNull(store.stored).items.size) + assertEquals(1, store.writes) + } + + @Test + fun reusedPasswordsFormAGroup() = runTest { + seed(0, 1, password = "same") + + val group = run().assertSuccess().groups.single() + + assertEquals(RelationType.Reused, group.dominantRelation) + assertEquals(setOf(id(0), id(1)), group.members.mapTo(mutableSetOf()) { it.itemId }) + } + + @Test + fun breachedPasswordsAreReported() = runTest { + seed(0, password = "password") + breached.breaches = mapOf("5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" to 3) + + val item = run().assertSuccess().standalone.single() + + assertEquals(listOf(ItemIssue.Breached(3)), item.issues) + } + + @Test + fun aFreshStoredReportIsServedWithoutScanning() = runTest { + seed(0, 1, score = PasswordScore.Weak) + val first = run().assertSuccess() + + val second = run().assertSuccess() + + assertEquals(first, second) + assertEquals(1, strength.calls.size) + assertEquals(1, breach.calls.size) + assertEquals(1, store.writes) + } + + @Test + fun anEditedPasswordTriggersAFreshScan() = runTest { + seed(0, 1) + run().assertSuccess() + + seed(1, password = "changed", score = PasswordScore.Weak) + val report = run().assertSuccess() + + assertEquals(2, strength.calls.size) + assertEquals(listOf(id(1)), report.standalone.map { it.itemId }) + assertEquals(setOf(id(1)), breach.calls.last()) + } + + @Test + fun aNewLoginTriggersAFreshScan() = runTest { + seed(0) + run().assertSuccess() + + seed(1) + val report = run().assertSuccess() + + assertEquals(2, report.totalPasswordsScanned) + assertEquals(setOf(id(1)), breach.calls.last()) + } + + @Test + fun aDeletedLoginDropsOutOfTheReport() = runTest { + seed(0, 1, score = PasswordScore.Weak) + run().assertSuccess() + + itemRepository.deleteItems(setOf(id(1))) + val report = run().assertSuccess() + + assertEquals(1, report.totalPasswordsScanned) + assertEquals(listOf(id(0)), report.standalone.map { it.itemId }) + } + + @Test + fun anExpiredBreachLookupOnlyRepeatsTheBreachCheck() = runTest { + seed(0, 1) + store.stored = StoredHealthReport( + items = listOf(0, 1).map { n -> + StoredHealthItem( + id = id(n), + fingerprint = assertNotNull(fingerprinter.fingerprint(loginOf(n))), + score = null, + breach = BreachResult(0, Instant.fromEpochSeconds(0)), + unreadable = false, + ) + }, + relationalFindings = emptyList(), + breachCheckEnabled = true, + ) + + run().assertSuccess() + + assertTrue(strength.calls.isEmpty()) + assertEquals(listOf(setOf(id(0), id(1))), breach.calls) + assertEquals(1, store.writes) + } + + @Test + fun turningTheBreachCheckOffRescansWithoutLookups() = runTest { + seed(0) + run().assertSuccess() + + breachState.setBreachEnabled(false) + val report = run().assertSuccess() + + assertEquals(1, breached.calls.size) + assertEquals(false, assertNotNull(store.stored).breachCheckEnabled) + assertEquals(GapReason.Disabled, report.gaps[CheckKind.Breach]?.reason) + } + + @Test + fun anUnreachableBreachServiceIsReportedAsAGap() = runTest { + seed(0) + breached.failures[prefixOf(loginOf(0))] = BreachedError.Unreachable + + val report = run().assertSuccess() + + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0))), report.gaps[CheckKind.Breach]) + } + + @Test + fun anUnreachableBreachServiceIsAskedAgainNextTime() = runTest { + seed(0) + breached.failures[prefixOf(loginOf(0))] = BreachedError.Unreachable + run().assertSuccess() + + breached.failures.clear() + val report = run().assertSuccess() + + assertEquals(emptyMap(), report.gaps) + assertEquals(1, strength.calls.size) + assertEquals(2, breach.calls.size) + } + + @Test + fun aReportThatCannotBeStoredIsStillReturned() = runTest { + seed(0, score = PasswordScore.Weak) + store.storeFailure = HealthReportStoreError.CryptoFailed + + val report = run().assertSuccess() + + assertEquals(listOf(id(0)), report.standalone.map { it.itemId }) + assertNull(store.stored) + } + + @Test + fun aStoredReportThatCannotBeReadIsScannedAgain() = runTest { + seed(0) + run().assertSuccess() + store.loadFailure = HealthReportStoreError.CorruptedReport + + run().assertSuccess() + + assertEquals(2, strength.calls.size) + } + + @Test + fun anUnreadablePasswordIsReportedAndForcesTheNextScan() = runTest { + seed(0) + loginRepository.seed(login(id(1))) + + val report = run().assertSuccess() + assertEquals(setOf(id(1)), report.unreadable) + + run().assertSuccess() + assertEquals(2, strength.calls.size) + } + + private suspend fun run() = useCase(EmptyCoroutineContext) + + /** Seeds logins whose passwords the fake provider can decrypt. */ + private fun seed( + vararg ns: Int, + password: String? = null, + score: PasswordScore = PasswordScore.Strong, + ) = ns.forEach { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed( + if (password == null) login(id(n), score = score) + else login(id(n), password = password, score = score), + ) + } + + private suspend fun loginOf(n: Int): Login = assertNotNull(loginRepository.getLoginById(id(n))) + + /** The range the breach check asks about for [login]'s password. */ + private fun prefixOf(login: Login): String { + val password = FakeCryptographicScopeProvider.transform( + login.passwordCredential!!.secret.payload.ciphertext, + ) + return MessageDigest.getInstance("SHA-1").digest(password) + .joinToString("") { "%02X".format(it) } + .take(5) + } + + private class Counting(private val delegate: PasswordHealthChecker) : PasswordHealthChecker { + + override val type = delegate.type + + val calls: MutableList> = Collections.synchronizedList(mutableListOf()) + + override suspend fun check(candidates: List): CheckOutcome { + calls += candidates.mapTo(mutableSetOf()) { it.id } + return delegate.check(candidates) + } + } + + private class FakeHealthReportStore : HealthReportStoreRepository { + + var stored: StoredHealthReport? = null + var writes = 0 + var storeFailure: HealthReportStoreError? = null + var loadFailure: HealthReportStoreError? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + storeFailure?.let { return Result.Failure(it) } + stored = report + writes++ + return Result.Success(Unit) + } + + override suspend fun load(): Result { + loadFailure?.let { return Result.Failure(it) } + return stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) + } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt new file mode 100644 index 000000000..21af07470 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -0,0 +1,445 @@ +package de.davis.keygo.feature.password_health.presentation + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasswordStrengthEstimator +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.item.view.domain.WebsiteHandler +import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository +import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.PasswordFixError +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.presentation.model.FixFlow +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState +import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import de.davis.keygo.rust.FakeTotpService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import java.util.Collections +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PasswordHealthViewModelTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) + private val vaultRepository = FakeVaultRepository() + private val breachState = FakeBreachCheckStateRepository(enabled = false) + private val websiteHandler = RecordingWebsiteHandler() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + vaultRepository.seed( + Vault( + id = VAULT_ID, + name = "Vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ), + ) + } + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = PasswordHealthViewModel( + passwordHealth = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = LoginFingerprinterImpl(), + healthReportStoreRepository = InMemoryStore(), + breachCheckStateRepository = breachState, + scanner = HealthReportScanner( + scopeProvider, + listOf( + WeakPasswordChecker(), + ReusePasswordCheck(), + BreachedPasswordCheck(FakeBreachedRepository(), breachState), + ), + ), + assembler = HealthReportAssembler(), + ), + createNewOrUpdateLogin = CreateNewOrUpdateLoginUseCase( + cryptographicScopeProvider = scopeProvider, + loginRepository = loginRepository, + vaultRepository = vaultRepository, + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, FakeCreditCardRepository()), + passwordStrengthEstimator = FakePasswordStrengthEstimator(PasswordScore.Strong), + totpService = FakeTotpService(), + ), + breachCheckStateRepository = breachState, + websiteHandler = websiteHandler, + ) + + @Test + fun startsOnTheFirstLoad() = runTest { + val vm = viewModel().also { subscribe(it) } + + assertEquals(RunPhase.FirstLoad, vm.uiState.value.phase) + assertTrue(vm.uiState.value.isFirstLoad) + } + + @Test + fun aRunReportsTheWeakPasswords() = runTest { + seed(0, score = PasswordScore.Weak) + seed(1) + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(RunPhase.Idle, state.phase) + assertEquals(2, state.totalPasswordCount) + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + assertEquals(listOf(FindingSeverity.Medium), state.healthSections.map { it.severity }) + assertEquals(listOf(id(0)), state.healthSections.single().standalone.map { it.itemId }) + assertNull(state.error) + } + + @Test + fun reusedPasswordsAreReportedAsAGroup() = runTest { + seed(0, password = "same") + seed(1, password = "same") + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(2, state.summary.reused) + assertEquals(FindingSeverity.High, state.healthSections.single().severity) + } + + @Test + fun aCleanVaultIsAllGood() = runTest { + seed(0) + val vm = viewModel().also { subscribe(it) } + + assertEquals(PasswordHealthStatus.ALL_GOOD, runAndAwait(vm).status) + } + + @Test + fun anEmptyVaultHasNoData() = runTest { + val vm = viewModel().also { subscribe(it) } + + val state = runAndAwait(vm) + + assertEquals(PasswordHealthReportError.NoPasswords, state.error) + assertEquals(PasswordHealthStatus.NO_DATA, state.status) + assertEquals(0, state.totalPasswordCount) + } + + @Test + fun aRefreshEndsIdleWithTheNewReport() = runTest { + seed(0) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + seed(1, score = PasswordScore.Weak) + vm.onEvent(PasswordHealthUiEvent.RefreshHealthCheck) + val state = vm.awaitIdle { it.totalPasswordCount == 2 } + + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + } + + @Test + fun theBreachCheckStateIsMirrored() = runTest { + val vm = viewModel().also { subscribe(it) } + + assertEquals(false, vm.uiState.value.breachCheckEnabled) + breachState.setBreachEnabled(true) + assertEquals(true, vm.uiState.first { it.breachCheckEnabled }.breachCheckEnabled) + } + + @Test + fun turningTheBreachCheckOnRunsAHealthCheck() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(true)) + val state = vm.awaitIdle { it.totalPasswordCount == 1 } + + assertEquals(true, breachState.state.value.enabled) + assertEquals(true, state.breachCheckEnabled) + } + + @Test + fun turningTheBreachCheckOffDoesNotRunAHealthCheck() = runTest { + breachState.setBreachEnabled(true) + seed(0) + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(false)) + + assertEquals(false, breachState.state.value.enabled) + assertEquals(RunPhase.FirstLoad, vm.uiState.value.phase) + assertEquals(0, vm.uiState.value.totalPasswordCount) + } + + @Test + fun clickingAnItemOpensIt() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.ItemClicked(id(3))) + + assertEquals(PasswordHealthEvent.OpenItem(id(3)), vm.events.first()) + } + + @Test + fun openingASiteGoesToTheWebsiteHandler() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.OpenSite("example.com")) + + assertEquals(listOf("example.com"), websiteHandler.opened) + } + + @Test + fun fixingStartsByGeneratingAPassword() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + + assertEquals(FixFlow.Generating(id(0)), vm.uiState.value.fixFlow) + } + + @Test + fun aGeneratedPasswordWaitsForConfirmation() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("n3w-Pa55word!")) + + assertEquals(FixFlow.Pending(id(0), "n3w-Pa55word!"), vm.uiState.value.fixFlow) + } + + @Test + fun aGeneratedPasswordWithoutAFixIsIgnored() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("n3w-Pa55word!")) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun dismissingTheGeneratorCancelsTheFix() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.DismissGeneratePassword) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun dismissingTheGeneratorKeepsAPendingFix() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("new")) + vm.onEvent(PasswordHealthUiEvent.DismissGeneratePassword) + + assertEquals(FixFlow.Pending(id(0), "new"), vm.uiState.value.fixFlow) + } + + @Test + fun discardingAPendingFixDropsIt() = runTest { + val vm = viewModel().also { subscribe(it) } + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.PasswordGenerated("new")) + vm.onEvent(PasswordHealthUiEvent.DiscardFix) + + assertNull(vm.uiState.value.fixFlow) + } + + @Test + fun confirmingWithoutAPendingFixDoesNothing() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.onEvent(PasswordHealthUiEvent.FixClicked(id(0))) + vm.onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + + assertEquals(FixFlow.Generating(id(0)), vm.uiState.value.fixFlow) + assertEquals("password-${id(0)}", passwordOf(0)) + } + + @Test + fun aConfirmedFixSavesThePasswordAndClearsTheFinding() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(0, "n3w-Pa55word!") + val state = vm.awaitIdle { + it.fixFlow == null && it.optimisticallyFixed.isEmpty() && it.healthSections.isEmpty() + } + + assertEquals("n3w-Pa55word!", passwordOf(0)) + assertEquals(PasswordHealthStatus.ALL_GOOD, state.status) + assertTrue(state.optimisticallyFixed.isEmpty()) + } + + @Test + fun aLockedVaultKeepsTheFixPendingAsLocked() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + scopeProvider.itemScopeFailure = CryptoScopeError.IdNotFound + + vm.fix(0, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals( + FixFlow.Pending(id(0), "new", applying = false, error = PasswordFixError.Locked), + state.fixFlow, + ) + assertTrue(state.optimisticallyFixed.isEmpty()) + assertEquals(listOf(id(0)), state.healthSections.single().standalone.map { it.itemId }) + } + + @Test + fun aFailedSaveKeepsTheFixPendingAsASaveError() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + loginRepository.createOrUpdateError = IllegalStateException("disk full") + + vm.fix(0, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals(PasswordFixError.Save, (state.fixFlow as FixFlow.Pending).error) + assertEquals("password-${id(0)}", passwordOf(0)) + } + + @Test + fun fixingAnItemThatIsGoneIsASaveError() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(9, "new") + val state = vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + assertEquals(PasswordFixError.Save, (state.fixFlow as FixFlow.Pending).error) + } + + @Test + fun aFailedFixCanBeRetried() = runTest { + seed(0, score = PasswordScore.Weak) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + loginRepository.createOrUpdateError = IllegalStateException("disk full") + vm.fix(0, "n3w-Pa55word!") + vm.uiState.first { (it.fixFlow as? FixFlow.Pending)?.error != null } + + vm.onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + vm.awaitIdle { + it.fixFlow == null && it.optimisticallyFixed.isEmpty() && it.healthSections.isEmpty() + } + + assertEquals("n3w-Pa55word!", passwordOf(0)) + } + + private fun PasswordHealthViewModel.fix(n: Int, password: String) { + onEvent(PasswordHealthUiEvent.FixClicked(id(n))) + onEvent(PasswordHealthUiEvent.PasswordGenerated(password)) + onEvent(PasswordHealthUiEvent.ConfirmPasswordChanged) + } + + private fun TestScope.subscribe(vm: PasswordHealthViewModel) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} } + } + + private suspend fun runAndAwait(vm: PasswordHealthViewModel): PasswordHealthUiState { + vm.onEvent(PasswordHealthUiEvent.RunHealthCheck) + return vm.uiState.first { it.phase == RunPhase.Idle } + } + + private suspend fun PasswordHealthViewModel.awaitIdle( + predicate: (PasswordHealthUiState) -> Boolean, + ) = uiState.first { it.phase == RunPhase.Idle && predicate(it) } + + private fun seed( + n: Int, + password: String = "password-${id(n)}", + score: PasswordScore = PasswordScore.Strong, + ) { + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed(login(id(n), password = password, score = score)) + } + + private suspend fun passwordOf(n: Int): String { + val login = loginRepository.getLoginById(id(n))!! + return FakeCryptographicScopeProvider.transform( + login.passwordCredential!!.secret.payload.ciphertext, + ).decodeToString() + } + + private class RecordingWebsiteHandler : WebsiteHandler { + val opened: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun openWebsite(url: String) { + opened += url + } + } + + private class InMemoryStore : HealthReportStoreRepository { + private var stored: StoredHealthReport? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + stored = report + return Result.Success(Unit) + } + + override suspend fun load(): Result = + stored?.let { Result.Success(it) } ?: Result.Failure(HealthReportStoreError.NoReportStored) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt new file mode 100644 index 000000000..f5b36fb96 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt @@ -0,0 +1,255 @@ +package de.davis.keygo.feature.password_health.presentation.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.domain.model.HealthFinding +import de.davis.keygo.feature.password_health.domain.model.ItemHealth +import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.RelatedGroup +import de.davis.keygo.feature.password_health.domain.model.RelationType +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PasswordHealthUiStateTest { + + private val weak = ItemIssue.Weak(PasswordScore.Weak) + private val breached = ItemIssue.Breached(3) + + @Test + fun anEmptyVaultHasNoData() { + assertEquals(PasswordHealthStatus.NO_DATA, PasswordHealthUiState().status) + } + + @Test + fun noPasswordsIsNoDataRatherThanAFailure() { + val state = PasswordHealthUiState(error = PasswordHealthReportError.NoPasswords) + + assertEquals(PasswordHealthStatus.NO_DATA, state.status) + } + + @Test + fun anyOtherErrorIsAFailure() { + val state = PasswordHealthUiState( + error = PasswordHealthReportError.Unreadable, + totalPasswordCount = 4, + ) + + assertEquals(PasswordHealthStatus.FAILED, state.status) + } + + @Test + fun checkedPasswordsWithoutFindingsAreAllGood() { + assertEquals(PasswordHealthStatus.ALL_GOOD, PasswordHealthUiState(totalPasswordCount = 3).status) + } + + @Test + fun anyFindingNeedsAttention() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))), + ) + + assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) + } + + @Test + fun fixingEveryFindingIsAllGood() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(PasswordHealthStatus.ALL_GOOD, state.status) + assertTrue(state.healthSections.isEmpty()) + } + + @Test + fun phasesAreReportedAsFlags() { + assertTrue(PasswordHealthUiState(phase = RunPhase.FirstLoad).isFirstLoad) + assertTrue(PasswordHealthUiState(phase = RunPhase.Refresh).isRefreshing) + PasswordHealthUiState(phase = RunPhase.Idle).let { + assertFalse(it.isFirstLoad) + assertFalse(it.isRefreshing) + } + } + + @Test + fun theFixFlowIsExposedByStep() { + val generating = PasswordHealthUiState(fixFlow = FixFlow.Generating(id(0))) + val pending = PasswordHealthUiState(fixFlow = FixFlow.Pending(id(0), "new")) + + assertEquals(FixFlow.Generating(id(0)), generating.generatingFix) + assertNull(generating.pendingFix) + assertEquals(FixFlow.Pending(id(0), "new"), pending.pendingFix) + assertNull(pending.generatingFix) + } + + @Test + fun exposesTheBreachGapAlone() { + val gap = CheckGap(GapReason.Unreachable, setOf(id(0))) + val state = PasswordHealthUiState( + checkGaps = mapOf( + CheckKind.Breach to gap, + CheckKind.Similarity to CheckGap(GapReason.Failed, setOf(id(1))), + ), + ) + + assertEquals(gap, state.breachGap) + assertNull(PasswordHealthUiState().breachGap) + } + + @Test + fun nothingFixedLeavesTheSectionsAsTheyAre() { + val sections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))) + + assertSame(sections, sections.withoutFixed(emptySet())) + } + + @Test + fun aFixedStandaloneItemLeavesItsSection() { + val sections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak), item(1, weak))), + ) + + val remaining = sections.withoutFixed(setOf(id(0))) + + assertEquals(listOf(id(1)), remaining.single().standalone.map { it.itemId }) + } + + @Test + fun aSectionLeftEmptyIsDropped() { + val sections = listOf( + section(FindingSeverity.Critical, standalone = listOf(item(0, breached))), + section(FindingSeverity.Medium, standalone = listOf(item(1, weak))), + ) + + val remaining = sections.withoutFixed(setOf(id(0))) + + assertEquals(listOf(FindingSeverity.Medium), remaining.map { it.severity }) + } + + @Test + fun aGroupThatKeepsTwoMembersStaysAGroup() { + val group = group(RelationType.Reused, item(0), item(1), item(2)) + val sections = listOf(section(FindingSeverity.High, groups = listOf(group))) + + val remaining = sections.withoutFixed(setOf(id(0))).single() + + assertEquals( + setOf(id(1), id(2)), + remaining.groups.single().members.mapTo(mutableSetOf()) { it.itemId }, + ) + assertTrue(remaining.standalone.isEmpty()) + } + + @Test + fun aGroupLeftWithOneCleanMemberDisappears() { + val sections = listOf( + section(FindingSeverity.High, groups = listOf(group(RelationType.Reused, item(0), item(1)))), + ) + + assertTrue(sections.withoutFixed(setOf(id(0))).isEmpty()) + } + + @Test + fun aGroupLeftWithOneFlaggedMemberFallsBackToStandalone() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf(group(RelationType.Reused, item(0), item(1, breached))), + standalone = listOf(item(2, weak)), + ), + ) + + val remaining = sections.withoutFixed(setOf(id(0))).single() + + assertTrue(remaining.groups.isEmpty()) + assertEquals(listOf(id(1), id(2)), remaining.standalone.map { it.itemId }) + } + + @Test + fun summaryCountsEveryFlaggedItemOnce() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf(group(RelationType.Reused, item(0, breached, weak), item(1))), + standalone = listOf(item(2, breached)), + ), + section(FindingSeverity.Medium, standalone = listOf(item(3, weak))), + ) + + assertEquals( + HealthSummary(needsAttention = 4, weak = 2, breached = 2, reused = 2, similar = 0), + sections.summary(), + ) + } + + @Test + fun summaryCountsTheItemsCoveredByEachRelation() { + val mixed = RelatedGroup( + id = id(0), + members = (0..3).mapTo(mutableSetOf()) { item(it) }, + relations = setOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2), id(3)), RelationType.Similar), + ), + ) + + val summary = listOf(section(FindingSeverity.High, groups = listOf(mixed))).summary() + + assertEquals(2, summary.reused) + assertEquals(3, summary.similar) + assertEquals(4, summary.needsAttention) + } + + @Test + fun summaryOfNothingIsAllZero() { + assertEquals(HealthSummary(0, 0, 0, 0, 0), emptyList().summary()) + } + + @Test + fun theSummaryFollowsTheFixedItems() { + val state = PasswordHealthUiState( + totalPasswordCount = 2, + reportedSections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak), item(1, weak))), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(1, state.summary.needsAttention) + assertEquals(1, state.summary.weak) + } + + private fun id(n: Int): ItemId = UUID(0L, n.toLong()) + + private fun item(n: Int, vararg issues: ItemIssue) = ItemHealth( + itemId = id(n), + title = "item-$n", + username = null, + issues = issues.toList(), + ) + + private fun group(type: RelationType, vararg members: ItemHealth) = RelatedGroup( + id = members.first().itemId, + members = members.toSet(), + relations = setOf(HealthFinding.Relation(members.mapTo(mutableSetOf()) { it.itemId }, type)), + ) + + private fun section( + severity: FindingSeverity, + groups: List = emptyList(), + standalone: List = emptyList(), + ) = HealthSection(severity, groups, standalone) +} From c5c7977b8a0c16b0dcb65e7383ac7da29dc4c051 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 16:06:59 +0200 Subject: [PATCH 33/54] fix: treat a corrupted stored health report as a cache miss --- .../data/mapper/StoredHealthReportMapper.kt | 28 ++++++--- .../HealthReportStoreRepositoryImpl.kt | 15 +++-- .../mapper/StoredHealthReportMapperTest.kt | 29 +++++++-- .../HealthReportStoreRepositoryImplTest.kt | 61 +++++++++++++++++++ 4 files changed, 115 insertions(+), 18 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt index b0888558c..ac36c3e26 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapper.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.password_health.data.mapper +import android.util.Log import com.google.protobuf.kotlin.toByteString import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore @@ -38,17 +39,23 @@ internal fun StoredHealthReport.toCompressedByteArray(): ByteArray = }.toByteArray() internal fun ByteArray.toStoredHealthReport(): StoredHealthReport? = - ProtoStoredHealthReport.parseFrom(GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) - .toDomain() + runCatching { + ProtoStoredHealthReport.parseFrom(GZIPInputStream(ByteArrayInputStream(this)).use { it.readBytes() }) + .toDomain() + }.getOrNull() private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { if (algorithmVersion != StoredHealthReport.ALGORITHM_VERSION) return null val items = itemsList.map(ProtoStoredItem::toDomain) - fun List.toItemIds(): Set = mapTo(HashSet(size)) { ref -> - items.getOrNull(ref)?.id - ?: throw IllegalStateException("Ref $ref out of range (${items.size} items)") + fun List.toItemIds(): Set? { + return mapTo(HashSet(size)) { ref -> + items.getOrNull(ref)?.id ?: run { + Log.w("StoredHealthReportMapper", "Ref $ref out of range (${items.size} items)") + return null + } + } } val gaps = HashMap(gapsCount) @@ -59,16 +66,21 @@ private fun ProtoStoredHealthReport.toDomain(): StoredHealthReport? { val gapValue = CheckGap( reason = reason, - unchecked = gap.uncheckedItemRefsList.toItemIds(), + unchecked = gap.uncheckedItemRefsList.toItemIds() ?: return null, ) if (gaps.put(kind, gapValue) != null) { - throw IllegalStateException("Duplicate gap for $kind") + Log.w("StoredHealthReportMapper", "Duplicate gap for $kind") + return null } } return StoredHealthReport( items = items, - relationalFindings = relationFindingList.mapNotNull { it.toDomain(it.itemRefsList.toItemIds()) }, + relationalFindings = relationFindingList.mapNotNull { + it.toDomain( + it.itemRefsList.toItemIds() ?: return null + ) + }, gaps = gaps, breachCheckEnabled = breachCheckEnabled, ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt index 8ffd79e06..f9f424e4d 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -16,6 +16,7 @@ import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davisalessandro.keygo.rust.ItemCryptoException import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single import java.util.UUID @@ -62,13 +63,17 @@ internal class HealthReportStoreRepositoryImpl( namespace = REPORT_NAMESPACE, wrapped = stored.toWrappedKey(), ) { - CryptographicData( - data = stored.ciphertext.toByteArray(), - iv = stored.iv.toByteArray(), - ).decrypt(REPORT_LABEL) + try { + CryptographicData( + data = stored.ciphertext.toByteArray(), + iv = stored.iv.toByteArray(), + ).decrypt(REPORT_LABEL) + } catch (_: ItemCryptoException) { + null + } }.bind { HealthReportStoreError.CryptoFailed } - compressedReport.toStoredHealthReport() + compressedReport?.toStoredHealthReport() .asResult(HealthReportStoreError.CorruptedReport) .bind() } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt index 923eca875..f99f7b6d6 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/mapper/StoredHealthReportMapperTest.kt @@ -18,9 +18,11 @@ import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import de.davis.keygo.feature.password_health.domain.report.breach import de.davis.keygo.feature.password_health.domain.report.id import de.davis.keygo.feature.password_health.domain.report.storedItem +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.IOException import java.util.UUID import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream @@ -31,6 +33,9 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.time.Instant +// Robolectric only so a rejected report can reach android.util.Log. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class StoredHealthReportMapperTest { private val checkedAt = Instant.fromEpochMilliseconds(1_700_000_000_123) @@ -224,7 +229,7 @@ class StoredHealthReportMapperTest { relationFinding += protoRelationFinding { itemRefs += listOf(0, 5) } } - assertFailsWith { bytes.toStoredHealthReport() } + assertNull(bytes.toStoredHealthReport()) } @Test @@ -234,7 +239,7 @@ class StoredHealthReportMapperTest { gaps += protoCheckGap { uncheckedItemRefs += 1 } } - assertFailsWith { bytes.toStoredHealthReport() } + assertNull(bytes.toStoredHealthReport()) } @Test @@ -245,7 +250,7 @@ class StoredHealthReportMapperTest { gaps += protoCheckGap { uncheckedItemRefs += 0 } } - assertFailsWith { bytes.toStoredHealthReport() } + assertNull(bytes.toStoredHealthReport()) } @Test @@ -277,7 +282,21 @@ class StoredHealthReportMapperTest { @Test fun bytesThatAreNotGzipAreRejected() { - assertFailsWith { byteArrayOf(1, 2, 3).toStoredHealthReport() } + assertNull(byteArrayOf(1, 2, 3).toStoredHealthReport()) + } + + @Test + fun aTruncatedProtoIsRejected() { + val bytes = ByteArrayOutputStream().also { bos -> + GZIPOutputStream(bos).use { it.write(byteArrayOf(0x0a, 0x7f, 0x01)) } + }.toByteArray() + + assertNull(bytes.toStoredHealthReport()) + } + + @Test + fun emptyBytesAreRejected() { + assertNull(byteArrayOf().toStoredHealthReport()) } private fun StoredHealthReport.roundTrip() = diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt index e6d55fc6b..0486a0a79 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt @@ -12,6 +12,7 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.local.model.protoRelationFinding import de.davis.keygo.feature.password_health.data.local.model.protoStoredHealthReport import de.davis.keygo.feature.password_health.data.mapper.toCompressedByteArray import de.davis.keygo.feature.password_health.data.mapper.toStoredHealthReport @@ -196,6 +197,66 @@ class HealthReportStoreRepositoryImplTest { assertTrue(opened is Result.Failure) } + @Test + fun aTamperedCiphertextIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + val tampered = stored.ciphertext.toByteArray().also { it[0] = (it[0] + 1).toByte() } + dataStore.state.value = stored.toBuilder().setCiphertext(tampered.toByteString()).build() + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aSwappedIvIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + val stored = dataStore.state.value + dataStore.state.value = stored.toBuilder() + .setIv(ByteArray(stored.iv.size()).toByteString()) + .build() + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportThatIsNotGzipIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(1, 2, 3)) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportThatIsNotAProtoIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(0x0a, 0x7f, 0x01).gzip()) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aReportPointingPastItsItemsIsCorrupted() = runTest { + repository.storeReport(report).assertSuccess() + seal( + protoStoredHealthReport { + algorithmVersion = StoredHealthReport.ALGORITHM_VERSION + relationFinding += protoRelationFinding { itemRefs += listOf(0, 1) } + }.toByteArray().gzip(), + ) + + assertEquals(HealthReportStoreError.CorruptedReport, repository.load().assertFailure()) + } + + @Test + fun aCorruptedReportCanBeOverwritten() = runTest { + repository.storeReport(report).assertSuccess() + seal(byteArrayOf(1, 2, 3)) + + repository.storeReport(report).assertSuccess() + + assertEquals(report, repository.load().assertSuccess()) + } + /** Replaces the stored report with [plaintext], sealed the way the repository seals one. */ private suspend fun seal(plaintext: ByteArray) { val stored = dataStore.state.value From c4f5c1b1e5cdc921a2e9625e584b0d83223eb929 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 18:56:11 +0200 Subject: [PATCH 34/54] feat: force full scan --- .../usecase/PasswordHealthReportUseCase.kt | 5 ++- .../presentation/PasswordHealthViewModel.kt | 13 ++++++-- .../PasswordHealthReportUseCaseTest.kt | 14 +++++++- .../PasswordHealthViewModelTest.kt | 32 ++++++++++++++++++- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index ef0f10854..e830fe2d2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -34,13 +34,16 @@ class PasswordHealthReportUseCase( ) { suspend operator fun invoke( + force: Boolean, coroutineContext: CoroutineContext = Dispatchers.Default, ): Result = resultBinding { withContext(coroutineContext) { val breachCheckEnabled = async { breachCheckStateRepository.getBreachCheckState().enabled } - val storedReport = async { healthReportStoreRepository.load().getOrNull() } + val storedReport = async { + if (force) null else healthReportStoreRepository.load().getOrNull() + } val logins = loginRepository.observeLogins() .firstOrNull() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 576f62a27..dc04d6776 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -59,7 +59,10 @@ internal class PasswordHealthViewModel( fun onEvent(event: PasswordHealthUiEvent) { when (event) { PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) - PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck(RunPhase.Refresh) + PasswordHealthUiEvent.RefreshHealthCheck -> runHealthCheck( + RunPhase.Refresh, + force = true + ) is PasswordHealthUiEvent.ItemClicked -> _eventChannel.trySend( PasswordHealthEvent.OpenItem(event.itemId) @@ -126,7 +129,11 @@ internal class PasswordHealthViewModel( } } - private fun runHealthCheck(phase: RunPhase, restartInFlight: Boolean = false) { + private fun runHealthCheck( + phase: RunPhase, + force: Boolean = false, + restartInFlight: Boolean = false, + ) { if (run?.isActive == true) { if (!restartInFlight) return run?.cancel() @@ -135,7 +142,7 @@ internal class PasswordHealthViewModel( run = viewModelScope.launch { _base.update { it.copy(phase = phase) } - val report = passwordHealth() + val report = passwordHealth(force = force) _base.update { state -> report.fold( onSuccess = { state.withReport(it) }, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt index 5222afcd1..f5b9d41b2 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -150,6 +150,18 @@ class PasswordHealthReportUseCaseTest { assertEquals(1, store.writes) } + @Test + fun aForcedRunScansEverythingAgain() = runTest { + seed(0, 1) + run().assertSuccess() + + run(force = true).assertSuccess() + + assertEquals(2, strength.calls.size) + assertEquals(listOf(setOf(id(0), id(1)), setOf(id(0), id(1))), breach.calls) + assertEquals(2, store.writes) + } + @Test fun anEditedPasswordTriggersAFreshScan() = runTest { seed(0, 1) @@ -282,7 +294,7 @@ class PasswordHealthReportUseCaseTest { assertEquals(2, strength.calls.size) } - private suspend fun run() = useCase(EmptyCoroutineContext) + private suspend fun run(force: Boolean = false) = useCase(force, EmptyCoroutineContext) /** Seeds logins whose passwords the fake provider can decrypt. */ private fun seed( diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index 21af07470..c4dd12ab3 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -65,6 +65,7 @@ class PasswordHealthViewModelTest { private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) private val vaultRepository = FakeVaultRepository() private val breachState = FakeBreachCheckStateRepository(enabled = false) + private val breached = FakeBreachedRepository() private val websiteHandler = RecordingWebsiteHandler() @BeforeTest @@ -94,7 +95,7 @@ class PasswordHealthViewModelTest { listOf( WeakPasswordChecker(), ReusePasswordCheck(), - BreachedPasswordCheck(FakeBreachedRepository(), breachState), + BreachedPasswordCheck(breached, breachState), ), ), assembler = HealthReportAssembler(), @@ -179,6 +180,33 @@ class PasswordHealthViewModelTest { assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) } + @Test + fun aRefreshLooksUpEveryPasswordInBreachesAgain() = runTest { + seed(0) + seed(1) + breachState.setBreachEnabled(true) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + assertEquals(2, breachLookups()) + + vm.onEvent(PasswordHealthUiEvent.RefreshHealthCheck) + vm.awaitIdle { breachLookups() == 4 } + } + + @Test + fun theRefreshAfterAFixOnlyLooksUpTheChangedPassword() = runTest { + seed(0, score = PasswordScore.Weak) + seed(1) + breachState.setBreachEnabled(true) + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + vm.fix(0, "n3w-Pa55word!") + vm.awaitIdle { it.fixFlow == null && it.optimisticallyFixed.isEmpty() } + + assertEquals(3, breachLookups()) + } + @Test fun theBreachCheckStateIsMirrored() = runTest { val vm = viewModel().also { subscribe(it) } @@ -400,6 +428,8 @@ class PasswordHealthViewModelTest { predicate: (PasswordHealthUiState) -> Boolean, ) = uiState.first { it.phase == RunPhase.Idle && predicate(it) } + private fun breachLookups() = breached.calls.sumOf { it.suffixes.size } + private fun seed( n: Int, password: String = "password-${id(n)}", From cf510916e11e8f8d89768f4c29b447c215c2067a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 20:56:14 +0200 Subject: [PATCH 35/54] feat: run health check on every app open --- .../keygo/app/presentation/AppViewModel.kt | 4 + .../keygo/app/presentation/MainActivity.kt | 4 + .../component/NavigationWrapper.kt | 63 +++++-- .../domain/PasswordHealthAttention.kt | 33 ++++ .../domain/UnlockHealthScan.kt | 27 +++ .../domain/model/PasswordHealthReport.kt | 4 +- .../usecase/PasswordHealthReportUseCase.kt | 16 ++ .../domain/PasswordHealthAttentionTest.kt | 35 ++++ .../domain/UnlockHealthScanTest.kt | 177 ++++++++++++++++++ .../PasswordHealthReportUseCaseTest.kt | 82 ++++++++ .../PasswordHealthViewModelTest.kt | 10 +- 11 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt index f7ef1770f..2cc51107c 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,6 +18,7 @@ internal class AppViewModel( private val accountRepository: AccountRepository, private val hasV1Password: HasMainPasswordUseCase, session: Session, + passwordHealthAttention: PasswordHealthAttention, ) : ViewModel() { private val _isReturningUser = MutableStateFlow(null) @@ -28,6 +30,8 @@ internal class AppViewModel( */ val isSessionActive: StateFlow = session.isActive + val needsAttentionCount: StateFlow = passwordHealthAttention.needsAttention + init { viewModelScope.launch { _isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() } 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 9005b6209..19a75d5f6 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 @@ -61,6 +61,7 @@ class MainActivity : FragmentActivity() { // Null until the account has been looked up, which the splash screen waits out. val hasAccess = viewModel.isReturningUser.collectAsState().value ?: return@setContent val isSessionActive by viewModel.isSessionActive.collectAsState() + val needsAttentionCount by viewModel.needsAttentionCount.collectAsState() KeyGoTheme { val snackbarManager = koinInject() @@ -71,6 +72,7 @@ class MainActivity : FragmentActivity() { hasAccess = hasAccess, launchRoute = launchRoute(hasAccess), isSessionActive = isSessionActive, + needsAttentionCount = needsAttentionCount, ) } } @@ -93,6 +95,7 @@ private fun App( hasAccess: Boolean, launchRoute: NavKey, isSessionActive: Boolean, + needsAttentionCount: Int, ) { val navigationState = rememberAppNavigationState( launchRoute = launchRoute, @@ -130,6 +133,7 @@ private fun App( onItemSelected = { type -> navigator.showDetail(RouteDestination.CreateItem(type)) }, showChrome = shell.showNavigation, showPrimaryActionButton = shell.showCreateButton, + needsAttentionCount = needsAttentionCount, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, 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 a74b0d721..83411ac4d 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 @@ -21,6 +21,8 @@ import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -87,7 +89,9 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign @@ -102,6 +106,7 @@ import de.davis.keygo.core.item.generated.presentation.presentation import kotlinx.coroutines.launch import kotlin.math.sign import de.davis.keygo.core.ui.R as CoreUiR +import de.davis.keygo.feature.password_health.R as PasswordHealthR @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -113,6 +118,7 @@ fun KeyGoNavigationWrapper( onItemSelected: (VaultItemType) -> Unit, showChrome: Boolean = true, showPrimaryActionButton: Boolean = true, + needsAttentionCount: Int = 0, containerColor: Color = NavigationSuiteScaffoldDefaults.containerColor, contentColor: Color = NavigationSuiteScaffoldDefaults.contentColor, buttonContainerColor: Color = MaterialTheme.colorScheme.tertiaryContainer, @@ -189,6 +195,7 @@ fun KeyGoNavigationWrapper( drawerState.close() } }, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -210,6 +217,7 @@ fun KeyGoNavigationWrapper( drawerState.open() } }, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor, ) @@ -351,6 +359,7 @@ fun KeyGoNavigationSuite( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor), ) { @@ -359,6 +368,7 @@ fun KeyGoNavigationSuite( KeyGoNavigationBar( selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, + needsAttentionCount = needsAttentionCount, ) } @@ -368,6 +378,7 @@ fun KeyGoNavigationSuite( navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, onOpenDrawer = onOpenDrawer, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -378,6 +389,7 @@ fun KeyGoNavigationSuite( selectedRoute = selectedRoute, onButtonClicked = onButtonClicked, navigateToTopLvlDestination = navigateToTopLvlDestination, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -391,18 +403,14 @@ fun KeyGoNavigationSuite( fun KeyGoNavigationBar( selectedRoute: NavKey?, navigateToTopLvlDestination: (NavKey) -> Unit, + needsAttentionCount: Int = 0, ) { NavigationBar { AppDestinations.entries.forEach { destination -> NavigationBarItem( selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = stringResource(destination.contentDescription) - ) - }, + icon = { DestinationIcon(destination, destination.badge(needsAttentionCount)) }, label = { Text(text = stringResource(destination.label)) }, alwaysShowLabel = false ) @@ -416,6 +424,7 @@ fun KeyGoNavigationRail( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -454,12 +463,7 @@ fun KeyGoNavigationRail( NavigationRailItem( selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = stringResource(destination.contentDescription) - ) - }, + icon = { DestinationIcon(destination, destination.badge(needsAttentionCount)) }, label = { Text(text = stringResource(destination.label)) }, alwaysShowLabel = false ) @@ -473,6 +477,7 @@ fun KeyGoNavigationDrawer( selectedRoute: NavKey?, navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -483,6 +488,7 @@ fun KeyGoNavigationDrawer( selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, + needsAttentionCount = needsAttentionCount, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor ) @@ -495,6 +501,7 @@ fun DrawerContent( navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onCloseDrawer: (() -> Unit)? = null, + needsAttentionCount: Int = 0, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) ) { @@ -565,12 +572,44 @@ fun DrawerContent( }, selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, + badge = destination.badge(needsAttentionCount), ) } } } } +private fun AppDestinations.badge(needsAttentionCount: Int): (@Composable () -> Unit)? = + if (this != AppDestinations.PASSWORD_HEALTH || needsAttentionCount <= 0) null + else ({ AttentionBadge(needsAttentionCount) }) + +@Composable +private fun AttentionBadge(count: Int) { + val description = pluralStringResource( + PasswordHealthR.plurals.password_health_needs_attention, + count, + count, + ) + Badge(modifier = Modifier.semantics { contentDescription = description }) { + Text(text = if (count > MaxBadgeCount) "$MaxBadgeCount+" else count.toString()) + } +} + +@Composable +private fun DestinationIcon(destination: AppDestinations, badge: (@Composable () -> Unit)?) { + val icon = @Composable { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.contentDescription), + ) + } + + if (badge != null) BadgedBox(badge = { badge.invoke() }) { icon() } + else icon() +} + +private const val MaxBadgeCount = 99 + @Composable private fun navigationInsets( layoutType: NavigationSuiteType, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt new file mode 100644 index 000000000..34d710254 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttention.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Single + +@Single +class PasswordHealthAttention( + session: Session, + @AppScopeQualifier appScope: CoroutineScope, +) { + + private val count = MutableStateFlow(0) + + // A scan that finishes after locking can still publish, so the lock state gates the value + // rather than a clear() that might run first. + val needsAttention: StateFlow = + combine(count, session.isActive) { count, active -> if (active) count else 0 } + .stateIn(appScope, SharingStarted.Eagerly, 0) + + internal fun update(count: Int) { + this.count.update { count } + } + + internal fun clear() = update(0) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt new file mode 100644 index 000000000..764fa4eeb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScan.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +@Single(createdAtStart = true) +internal class UnlockHealthScan( + session: Session, + passwordHealth: PasswordHealthReportUseCase, + attention: PasswordHealthAttention, + @AppScopeQualifier appScope: CoroutineScope, +) { + + init { + appScope.launch { + session.isActive.collectLatest { active -> + if (active) passwordHealth(force = false) + else attention.clear() + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index 0d10327b8..7958aff43 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -47,4 +47,6 @@ data class PasswordHealthReport( val totalPasswordsScanned: Int, val gaps: Map = emptyMap(), val unreadable: Set = emptySet(), -) +) { + val needsAttentionCount: Int = groups.sumOf { it.members.size } + standalone.size +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index e830fe2d2..2e603e9c5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -6,8 +6,10 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler @@ -18,6 +20,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import kotlin.coroutines.CoroutineContext @@ -31,11 +35,23 @@ class PasswordHealthReportUseCase( private val breachCheckStateRepository: BreachCheckStateRepository, private val scanner: HealthReportScanner, private val assembler: HealthReportAssembler, + private val attention: PasswordHealthAttention, ) { + private val mutex = Mutex() + suspend operator fun invoke( force: Boolean, coroutineContext: CoroutineContext = Dispatchers.Default, + ): Result = mutex.withLock { + report(force, coroutineContext) + .onSuccess { attention.update(it.needsAttentionCount) } + .onFailure { if (it == PasswordHealthReportError.NoPasswords) attention.clear() } + } + + private suspend fun report( + force: Boolean, + coroutineContext: CoroutineContext, ): Result = resultBinding { withContext(coroutineContext) { val breachCheckEnabled = async { diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt new file mode 100644 index 000000000..7e2bd20f2 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/PasswordHealthAttentionTest.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.security.FakeSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class PasswordHealthAttentionTest { + + private val scope = CoroutineScope(Dispatchers.Unconfined) + + @AfterTest + fun tearDown() = scope.cancel() + + @Test + fun anUnlockedSessionShowsTheCount() { + val attention = PasswordHealthAttention(FakeSession(startUnlocked = true), scope) + + attention.update(3) + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun aCountPublishedWhileLockedStaysHidden() { + val attention = PasswordHealthAttention(FakeSession(), scope) + + attention.update(3) + + assertEquals(0, attention.needsAttention.value) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt new file mode 100644 index 000000000..f878f8d34 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt @@ -0,0 +1,177 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker +import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker +import de.davis.keygo.feature.password_health.domain.model.CheckKind +import de.davis.keygo.feature.password_health.domain.model.CheckOutcome +import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate +import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler +import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.report.VAULT_ID +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +// The use case does its work on Dispatchers.Default, which runTest's scheduler does not drive, so +// these run on real threads and wait on the observable count instead of on virtual time. +class UnlockHealthScanTest { + + private val session = FakeSession() + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val breachState = FakeBreachCheckStateRepository(enabled = false) + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val attention = PasswordHealthAttention(session, appScope) + + @AfterTest + fun tearDown() = appScope.cancel() + + @Test + fun unlockingScansTheVaultAndPublishesTheCount() = runBlocking(Dispatchers.Default) { + seedWeak(0, 1) + startWith() + + unlock() + + awaitCount(2) + } + + @Test + fun lockingClearsTheCount() = runBlocking(Dispatchers.Default) { + seedWeak(0, 1) + startWith() + unlock() + awaitCount(2) + + session.endSession() + + awaitCount(0) + } + + @Test + fun everyUnlockScansAgain() = runBlocking(Dispatchers.Default) { + seedWeak(0) + startWith() + unlock() + awaitCount(1) + session.endSession() + awaitCount(0) + seedWeak(1) + + unlock() + + awaitCount(2) + } + + @Test + fun lockingCancelsAScanInFlight() = runBlocking(Dispatchers.Default) { + seedWeak(0) + val blocked = Blocking() + startWith(listOf(blocked)) + unlock() + withTimeout(TIMEOUT) { blocked.entered.await() } + + session.endSession() + + withTimeout(TIMEOUT) { blocked.cancelled.await() } + } + + private fun startWith( + checkers: List = listOf(WeakPasswordChecker()), + ) = UnlockHealthScan( + session = session, + passwordHealth = PasswordHealthReportUseCase( + loginRepository = loginRepository, + loginFingerprinter = LoginFingerprinterImpl(), + healthReportStoreRepository = InMemoryStore(), + breachCheckStateRepository = breachState, + scanner = HealthReportScanner(FakeCryptographicScopeProvider(itemRepository), checkers), + assembler = HealthReportAssembler(), + attention = attention, + ), + attention = attention, + appScope = appScope, + ) + + private suspend fun unlock() { + session.unlockWithArk(ByteArray(ARK_SIZE)) + } + + private suspend fun awaitCount(count: Int) { + withTimeout(TIMEOUT) { attention.needsAttention.first { it == count } } + } + + private fun seedWeak(vararg ns: Int) = ns.forEach { n -> + itemRepository.seedEnvelope( + ItemKeyEnvelope( + vaultId = VAULT_ID, + itemId = id(n), + itemKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + vaultKeyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ), + ) + loginRepository.seed(login(id(n), score = PasswordScore.Weak)) + } + + private class Blocking : PasswordHealthChecker { + + override val type = CheckKind.Strength + + val entered = CompletableDeferred() + val cancelled = CompletableDeferred() + + override suspend fun check(candidates: List): CheckOutcome { + entered.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelled.complete(Unit) + throw e + } + } + } + + private class InMemoryStore : HealthReportStoreRepository { + private var stored: StoredHealthReport? = null + + override suspend fun storeReport(report: StoredHealthReport): Result { + stored = report + return Result.Success(Unit) + } + + override suspend fun load(): Result = + stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) + } + + private companion object { + val TIMEOUT = 5.seconds + const val ARK_SIZE = 32 + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt index f5b9d41b2..7edd16e42 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.assertFailure @@ -14,6 +15,7 @@ import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck @@ -37,6 +39,11 @@ import de.davis.keygo.feature.password_health.domain.report.VAULT_ID import de.davis.keygo.feature.password_health.domain.report.id import de.davis.keygo.feature.password_health.domain.report.login import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -63,6 +70,10 @@ class PasswordHealthReportUseCaseTest { private val store = FakeHealthReportStore() private val breachState = FakeBreachCheckStateRepository(enabled = true) private val breached = FakeBreachedRepository() + private val attention = PasswordHealthAttention( + session = FakeSession(startUnlocked = true), + appScope = CoroutineScope(Dispatchers.Unconfined), + ) private val strength = Counting(WeakPasswordChecker()) private val reuse = Counting(ReusePasswordCheck()) @@ -75,6 +86,7 @@ class PasswordHealthReportUseCaseTest { breachCheckStateRepository = breachState, scanner = HealthReportScanner(scopeProvider, listOf(strength, reuse, breach)), assembler = HealthReportAssembler(), + attention = attention, ) @Test @@ -127,6 +139,47 @@ class PasswordHealthReportUseCaseTest { assertEquals(setOf(id(0), id(1)), group.members.mapTo(mutableSetOf()) { it.itemId }) } + @Test + fun onlyTheItemsThatNeedAttentionAreCounted() = runTest { + seed(0, 1, score = PasswordScore.Weak) + seed(2) + + run().assertSuccess() + + assertEquals(2, attention.needsAttention.value) + } + + @Test + fun everyMemberOfAGroupIsCountedNotTheGroup() = runTest { + seed(0, 1, password = "same") + seed(2, score = PasswordScore.Weak) + + run().assertSuccess() + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun aReportServedFromTheStoreIsCountedToo() = runTest { + seed(0, 1, score = PasswordScore.Weak) + run().assertSuccess() + attention.clear() + + run().assertSuccess() + + assertEquals(1, store.writes) + assertEquals(2, attention.needsAttention.value) + } + + @Test + fun aVaultThatBecameEmptyClearsTheCount() = runTest { + attention.update(3) + + assertEquals(PasswordHealthReportError.NoPasswords, run().assertFailure()) + + assertEquals(0, attention.needsAttention.value) + } + @Test fun breachedPasswordsAreReported() = runTest { seed(0, password = "password") @@ -294,6 +347,32 @@ class PasswordHealthReportUseCaseTest { assertEquals(2, strength.calls.size) } + @Test + fun aScanThatFailsLeavesTheCountAlone() = runTest { + attention.update(3) + loginRepository.seed(login(id(0))) + + assertEquals(PasswordHealthReportError.Unreadable, run().assertFailure()) + + assertEquals(3, attention.needsAttention.value) + } + + @Test + fun callersThatOverlapShareOneScan() = runTest { + seed(0) + val gate = CompletableDeferred() + strength.gate = gate + + val first = async { run() } + val second = async { run() } + runCurrent() + gate.complete(Unit) + + first.await().assertSuccess() + second.await().assertSuccess() + assertEquals(1, strength.calls.size) + } + private suspend fun run(force: Boolean = false) = useCase(force, EmptyCoroutineContext) /** Seeds logins whose passwords the fake provider can decrypt. */ @@ -334,8 +413,11 @@ class PasswordHealthReportUseCaseTest { val calls: MutableList> = Collections.synchronizedList(mutableListOf()) + var gate: CompletableDeferred? = null + override suspend fun check(candidates: List): CheckOutcome { calls += candidates.mapTo(mutableSetOf()) { it.id } + gate?.await() return delegate.check(candidates) } } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index c4dd12ab3..c56ae2190 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -10,6 +10,7 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result @@ -18,6 +19,7 @@ import de.davis.keygo.feature.item.view.domain.WebsiteHandler import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker @@ -40,6 +42,7 @@ import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthU import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase import de.davis.keygo.rust.FakeTotpService +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -99,6 +102,10 @@ class PasswordHealthViewModelTest { ), ), assembler = HealthReportAssembler(), + attention = PasswordHealthAttention( + FakeSession(), + CoroutineScope(Dispatchers.Unconfined) + ), ), createNewOrUpdateLogin = CreateNewOrUpdateLoginUseCase( cryptographicScopeProvider = scopeProvider, @@ -470,6 +477,7 @@ class PasswordHealthViewModelTest { } override suspend fun load(): Result = - stored?.let { Result.Success(it) } ?: Result.Failure(HealthReportStoreError.NoReportStored) + stored?.let { Result.Success(it) } + ?: Result.Failure(HealthReportStoreError.NoReportStored) } } From 75309d42caa089805ad0fbaf8cae806664438f3a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 21:26:49 +0200 Subject: [PATCH 36/54] feat: check connectivity bevor performing breach check --- .../src/main/AndroidManifest.xml | 1 + .../repository/ConnectivityRepositoryImpl.kt | 23 +++++++++++++++++++ .../domain/checker/BreachedPasswordCheck.kt | 5 ++++ .../repository/ConnectivityRepository.kt | 5 ++++ .../FakeConnectivityRepository.kt | 8 +++++++ .../checker/BreachedPasswordCheckTest.kt | 14 ++++++++++- .../PasswordHealthReportUseCaseTest.kt | 5 +++- .../PasswordHealthViewModelTest.kt | 3 ++- 8 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt diff --git a/feature/password-health/src/main/AndroidManifest.xml b/feature/password-health/src/main/AndroidManifest.xml index 458644fa1..aa8b9f31b 100644 --- a/feature/password-health/src/main/AndroidManifest.xml +++ b/feature/password-health/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt new file mode 100644 index 000000000..d3f511fa0 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.password_health.data.repository + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import org.koin.core.annotation.Single + +@Single +internal class ConnectivityRepositoryImpl(context: Context) : ConnectivityRepository { + + private val connectivity = context.getSystemService(ConnectivityManager::class.java) + + override fun hasInternet(): Boolean { + val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) + ?: return false + + // Validated rules out a network that is joined but has no way out, which is where every + // lookup would otherwise hang until its timeout. + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt index de828cad3..a7a10d5d8 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -14,6 +14,7 @@ import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -27,6 +28,7 @@ import java.security.MessageDigest internal class BreachedPasswordCheck( private val breachedRepository: BreachedRepository, private val breachCheckStateRepository: BreachCheckStateRepository, + private val connectivityRepository: ConnectivityRepository, ) : PasswordHealthChecker { override val type = CheckKind.Breach @@ -37,6 +39,9 @@ internal class BreachedPasswordCheck( if (!breachCheckStateRepository.getBreachCheckState().enabled) return CheckOutcome.skipped(GapReason.Disabled, candidates) + if (!connectivityRepository.hasInternet()) + return CheckOutcome.skipped(GapReason.Unreachable, candidates) + val ranges = candidates.byRange() val answers = coroutineScope { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt new file mode 100644 index 000000000..e8be1bfdd --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.password_health.domain.repository + +interface ConnectivityRepository { + fun hasInternet(): Boolean +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt new file mode 100644 index 000000000..59492cea8 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository + +internal class FakeConnectivityRepository(var online: Boolean = true) : ConnectivityRepository { + + override fun hasInternet(): Boolean = online +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt index b34a31459..99f7ba6bc 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.password_health.domain.checker import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository import de.davis.keygo.feature.password_health.domain.model.BreachedError import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind @@ -25,7 +26,8 @@ class BreachedPasswordCheckTest { private val breached = FakeBreachedRepository() private val state = FakeBreachCheckStateRepository(enabled = true) - private val checker = BreachedPasswordCheck(breached, state) + private val connectivity = FakeConnectivityRepository(online = true) + private val checker = BreachedPasswordCheck(breached, state, connectivity) @Test fun reportsAsTheBreachCheck() { @@ -123,6 +125,16 @@ class BreachedPasswordCheckTest { assertEquals(CheckGap(GapReason.Disabled, setOf(id(0), id(1))), outcome.gap) } + @Test + fun noInternetSkipsEveryPasswordWithoutAsking() = runTest { + connectivity.online = false + + val outcome = checker.check(candidates("password", "hunter2")) + + assertTrue(breached.calls.isEmpty()) + assertEquals(CheckGap(GapReason.Unreachable, setOf(id(0), id(1))), outcome.gap) + } + @Test fun anUnreachableRangeLeavesItsPasswordsUnchecked() = runTest { breached.failures["5BAA6"] = BreachedError.Unreachable diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt index 7edd16e42..152c75532 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -14,6 +14,7 @@ import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck @@ -77,7 +78,9 @@ class PasswordHealthReportUseCaseTest { private val strength = Counting(WeakPasswordChecker()) private val reuse = Counting(ReusePasswordCheck()) - private val breach = Counting(BreachedPasswordCheck(breached, breachState)) + private val breach = Counting( + BreachedPasswordCheck(breached, breachState, FakeConnectivityRepository()), + ) private val useCase = PasswordHealthReportUseCase( loginRepository = loginRepository, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index c56ae2190..27a9f77a2 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -18,6 +18,7 @@ import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUse import de.davis.keygo.feature.item.view.domain.WebsiteHandler import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository +import de.davis.keygo.feature.password_health.FakeConnectivityRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck @@ -98,7 +99,7 @@ class PasswordHealthViewModelTest { listOf( WeakPasswordChecker(), ReusePasswordCheck(), - BreachedPasswordCheck(breached, breachState), + BreachedPasswordCheck(breached, breachState, FakeConnectivityRepository()), ), ), assembler = HealthReportAssembler(), From 402215e4d1271741909e204ef164a98b5ec344d5 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 22:32:08 +0200 Subject: [PATCH 37/54] feat: add notifier --- .../src/main/AndroidManifest.xml | 1 + .../password_health/data/NotifierImpl.kt | 119 ++++++++++++++++++ .../password_health/domain/Notifier.kt | 9 ++ .../domain/model/KeyGoNotification.kt | 10 ++ .../src/main/res/values/strings.xml | 3 + 5 files changed, 142 insertions(+) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt diff --git a/feature/password-health/src/main/AndroidManifest.xml b/feature/password-health/src/main/AndroidManifest.xml index aa8b9f31b..ae03cb5ce 100644 --- a/feature/password-health/src/main/AndroidManifest.xml +++ b/feature/password-health/src/main/AndroidManifest.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt new file mode 100644 index 000000000..0803fd065 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt @@ -0,0 +1,119 @@ +package de.davis.keygo.feature.password_health.data + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.isEmpty +import org.koin.core.annotation.Single +import de.davis.keygo.core.ui.R as CoreUiR + +@Single +internal class NotifierImpl( + private val context: Context, +) : Notifier { + + private val notificationManagerCompat by lazy { NotificationManagerCompat.from(context) } + + override fun canNotify(): Boolean { + val permissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + else true + + return permissionGranted && notificationManagerCompat.areNotificationsEnabled() + } + + @Suppress("MissingPermission") + override fun sendNotification(notification: KeyGoNotification) { + if (notification.isEmpty) { + notificationManagerCompat.cancel(notification.id) + return + } + if (!canNotify()) return + + createChannels() + notificationManagerCompat.notify(notification.id, notification.buildNotification()) + } + + private fun createChannels() { + val channels = Channel.entries.map { + NotificationChannelCompat.Builder(it.id, it.importance) + .setName(context.getString(it.nameRes)) + .setDescription(context.getString(it.descriptionRes)) + .build() + } + notificationManagerCompat.createNotificationChannelsCompat(channels) + } + + private fun KeyGoNotification.buildNotification(): Notification = when (this) { + is KeyGoNotification.NeedsAttention -> baseNotification() + .setContentText( + context.resources.getQuantityString( + R.plurals.password_health_needs_attention, + count, + count, + ), + ) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(baseNotification().build()) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setContentIntent(openAppIntent()) + .build() + } + + private fun KeyGoNotification.baseNotification() = + NotificationCompat.Builder(context, channel.id) + .setSmallIcon(CoreUiR.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.password_health_title)) + + // TODO: send user to correct screen + private fun openAppIntent(): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: return null + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private val KeyGoNotification.channel: Channel + get() = when (this) { + is KeyGoNotification.NeedsAttention -> Channel.PasswordHealth + } + + private val KeyGoNotification.id: Int + get() = when (this) { + is KeyGoNotification.NeedsAttention -> 1001 + } + + private enum class Channel( + val id: String, + val importance: Int, + @param:StringRes val nameRes: Int, + @param:StringRes val descriptionRes: Int, + ) { + PasswordHealth( + id = "password_health_channel", + importance = NotificationManagerCompat.IMPORTANCE_LOW, + nameRes = R.string.password_health_notification_channel_name, + descriptionRes = R.string.password_health_notification_channel_description, + ), + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt new file mode 100644 index 000000000..a6abc4ebb --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.password_health.domain + +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification + +interface Notifier { + + fun canNotify(): Boolean + fun sendNotification(notification: KeyGoNotification) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt new file mode 100644 index 000000000..cf0471e68 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.password_health.domain.model + +sealed interface KeyGoNotification { + data class NeedsAttention(val count: Int) : KeyGoNotification +} + +val KeyGoNotification.isEmpty: Boolean + get() = when (this) { + is KeyGoNotification.NeedsAttention -> count <= 0 + } diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 393316327..44f4074f1 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -72,4 +72,7 @@ Couldn\'t save the new password. Try again. Pick an item to see it here + + Password health + Reminders when some of your passwords need attention From 40626f9e00f2720a587625b467c89030fad9e515 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 26 Sep 2026 23:32:26 +0200 Subject: [PATCH 38/54] refactor: turn breach check state into health settings with a notification toggle --- .../data/mapper/BreachCheckStateMapper.kt | 9 +- .../BreachCheckStateRepositoryImpl.kt | 31 ------- .../HealthSettingsRepositoryImpl.kt | 37 +++++++++ .../di/FeaturePasswordHealthModule.kt | 17 ++-- .../di/annotation/HealthSettingsQualifier.kt | 6 ++ .../domain/checker/BreachedPasswordCheck.kt | 6 +- .../domain/model/BreachCheckState.kt | 5 -- .../domain/model/HealthSettings.kt | 6 ++ .../repository/BreachCheckStateRepository.kt | 10 --- .../repository/HealthSettingsRepository.kt | 11 +++ .../usecase/PasswordHealthReportUseCase.kt | 6 +- .../presentation/PasswordHealthContent.kt | 8 +- .../presentation/PasswordHealthViewModel.kt | 17 ++-- .../presentation/component/BreachCheck.kt | 61 ++++++++++++-- .../model/PasswordHealthUiEvent.kt | 1 + .../model/PasswordHealthUiState.kt | 1 + ...heck_state.proto => health_settings.proto} | 5 +- .../FakeBreachCheckStateRepository.kt | 19 ----- .../FakeHealthSettingsRepository.kt | 32 ++++++++ .../BreachCheckStateRepositoryImplTest.kt | 62 -------------- .../HealthSettingsRepositoryImplTest.kt | 82 +++++++++++++++++++ .../domain/UnlockHealthScanTest.kt | 6 +- .../checker/BreachedPasswordCheckTest.kt | 11 ++- .../PasswordHealthReportUseCaseTest.kt | 6 +- .../PasswordHealthViewModelTest.kt | 12 +-- 25 files changed, 288 insertions(+), 179 deletions(-) delete mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt delete mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt delete mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt rename feature/password-health/src/main/proto/{breach_check_state.proto => health_settings.proto} (57%) delete mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt delete mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt index d51498a5a..45cb29ee9 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/BreachCheckStateMapper.kt @@ -1,8 +1,9 @@ package de.davis.keygo.feature.password_health.data.mapper -import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState -import de.davis.keygo.feature.password_health.domain.model.BreachCheckState +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.domain.model.HealthSettings -internal fun ProtoBreachCheckState.toDomain() = BreachCheckState( - enabled = enabled +internal fun ProtoHealthSettings.toDomain() = HealthSettings( + breachesEnabled = breachesEnabled, + notificationsEnabled = notificationsEnabled ) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt deleted file mode 100644 index 71627a335..000000000 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImpl.kt +++ /dev/null @@ -1,31 +0,0 @@ -package de.davis.keygo.feature.password_health.data.repository - -import androidx.datastore.core.DataStore -import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState -import de.davis.keygo.feature.password_health.data.mapper.toDomain -import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier -import de.davis.keygo.feature.password_health.domain.model.BreachCheckState -import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import org.koin.core.annotation.Single - -@Single -internal class BreachCheckStateRepositoryImpl( - @BreachedQualifier - private val dataStore: DataStore, -) : BreachCheckStateRepository { - - override fun observeBreachCheckState(): Flow = dataStore.data - .map(ProtoBreachCheckState::toDomain) - - override suspend fun getBreachCheckState(): BreachCheckState = - dataStore.data.first().toDomain() - - override suspend fun setBreachEnabled(enabled: Boolean) { - dataStore.updateData { - it.toBuilder().setEnabled(enabled).build() - } - } -} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt new file mode 100644 index 000000000..a85a34641 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImpl.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.data.mapper.toDomain +import de.davis.keygo.feature.password_health.di.annotation.HealthSettingsQualifier +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class HealthSettingsRepositoryImpl( + @HealthSettingsQualifier + private val dataStore: DataStore, +) : HealthSettingsRepository { + + override fun observeBreachCheckState(): Flow = dataStore.data + .map(ProtoHealthSettings::toDomain) + + override suspend fun getBreachCheckState(): HealthSettings = + dataStore.data.first().toDomain() + + override suspend fun setBreachEnabled(enabled: Boolean) { + dataStore.updateData { + it.toBuilder().setBreachesEnabled(enabled).build() + } + } + + override suspend fun setNotificationEnabled(enabled: Boolean) { + dataStore.updateData { + it.toBuilder().setNotificationsEnabled(enabled).build() + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 84f64f81e..034ba3fa0 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -3,10 +3,11 @@ package de.davis.keygo.feature.password_health.di import android.content.Context import androidx.datastore.dataStore import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer -import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore +import de.davis.keygo.feature.password_health.di.annotation.HealthSettingsQualifier import okhttp3.Dispatcher import okhttp3.OkHttpClient import org.koin.core.annotation.ComponentScan @@ -40,11 +41,11 @@ object FeaturePasswordHealthModule { .build() } - private val Context.breachCheckStateDataStore by dataStore( - "breach_check_state.pb", + private val Context.healthSettingsDataStore by dataStore( + "health_settings.pb", DefaultProtoSerializer( - defaultInstance = ProtoBreachCheckState.getDefaultInstance(), - parser = ProtoBreachCheckState.parser() + defaultInstance = ProtoHealthSettings.getDefaultInstance(), + parser = ProtoHealthSettings.parser() ) ) @@ -57,9 +58,9 @@ object FeaturePasswordHealthModule { ) @Single - @BreachedQualifier - internal fun provideBreachCheckStateDataStore(context: Context) = - context.breachCheckStateDataStore + @HealthSettingsQualifier + internal fun provideHealthSettingsDataStore(context: Context) = + context.healthSettingsDataStore @Single @HealthReportStore diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt new file mode 100644 index 000000000..babb315ed --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthSettingsQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthSettingsQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt index a7a10d5d8..c7b50a8e5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -11,10 +11,10 @@ import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.HealthFinding import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate -import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository import de.davis.keygo.feature.password_health.domain.repository.BreachedRepository.Companion.PREFIX_LENGTH import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -27,7 +27,7 @@ import java.security.MessageDigest @Single internal class BreachedPasswordCheck( private val breachedRepository: BreachedRepository, - private val breachCheckStateRepository: BreachCheckStateRepository, + private val healthSettingsRepository: HealthSettingsRepository, private val connectivityRepository: ConnectivityRepository, ) : PasswordHealthChecker { @@ -36,7 +36,7 @@ internal class BreachedPasswordCheck( override suspend fun check(candidates: List): CheckOutcome { if (candidates.isEmpty()) return CheckOutcome() - if (!breachCheckStateRepository.getBreachCheckState().enabled) + if (!healthSettingsRepository.getBreachCheckState().breachesEnabled) return CheckOutcome.skipped(GapReason.Disabled, candidates) if (!connectivityRepository.hasInternet()) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt deleted file mode 100644 index abfe3ca38..000000000 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/BreachCheckState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.davis.keygo.feature.password_health.domain.model - -data class BreachCheckState( - val enabled: Boolean, -) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt new file mode 100644 index 000000000..be5a343a9 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthSettings.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.domain.model + +data class HealthSettings( + val breachesEnabled: Boolean, + val notificationsEnabled: Boolean, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt deleted file mode 100644 index 50d7fa43c..000000000 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/BreachCheckStateRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.feature.password_health.domain.repository - -import de.davis.keygo.feature.password_health.domain.model.BreachCheckState -import kotlinx.coroutines.flow.Flow - -interface BreachCheckStateRepository { - fun observeBreachCheckState(): Flow - suspend fun getBreachCheckState(): BreachCheckState - suspend fun setBreachEnabled(enabled: Boolean) -} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt new file mode 100644 index 000000000..052b87f58 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthSettingsRepository.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import kotlinx.coroutines.flow.Flow + +interface HealthSettingsRepository { + fun observeBreachCheckState(): Flow + suspend fun getBreachCheckState(): HealthSettings + suspend fun setBreachEnabled(enabled: Boolean) + suspend fun setNotificationEnabled(enabled: Boolean) +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 2e603e9c5..34a853fd8 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -14,8 +14,8 @@ import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner -import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -32,7 +32,7 @@ class PasswordHealthReportUseCase( private val loginRepository: LoginRepository, private val loginFingerprinter: LoginFingerprinter, private val healthReportStoreRepository: HealthReportStoreRepository, - private val breachCheckStateRepository: BreachCheckStateRepository, + private val healthSettingsRepository: HealthSettingsRepository, private val scanner: HealthReportScanner, private val assembler: HealthReportAssembler, private val attention: PasswordHealthAttention, @@ -55,7 +55,7 @@ class PasswordHealthReportUseCase( ): Result = resultBinding { withContext(coroutineContext) { val breachCheckEnabled = async { - breachCheckStateRepository.getBreachCheckState().enabled + healthSettingsRepository.getBreachCheckState().breachesEnabled } val storedReport = async { if (force) null else healthReportStoreRepository.load().getOrNull() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt index 400540430..4ca8a3f55 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthContent.kt @@ -34,7 +34,7 @@ import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.ItemIssue -import de.davis.keygo.feature.password_health.presentation.component.BreachCheck +import de.davis.keygo.feature.password_health.presentation.component.HealthSettings import de.davis.keygo.feature.password_health.presentation.component.PasswordHealthStatus import de.davis.keygo.feature.password_health.presentation.component.needsAttentionSection import de.davis.keygo.feature.password_health.presentation.model.HealthSection @@ -94,11 +94,11 @@ internal fun PasswordHealthContent( onEvent = onEvent, ) - item(key = "breach_check") { + item(key = "settings") { Spacer(modifier = Modifier.height(28.dp)) - BreachCheck( + HealthSettings( state = state, - onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, + onEvent = onEvent, modifier = Modifier.animateItem() ) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index dc04d6776..d4f6f7246 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -12,7 +12,7 @@ import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError -import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection @@ -35,7 +35,7 @@ import org.koin.core.annotation.KoinViewModel internal class PasswordHealthViewModel( private val passwordHealth: PasswordHealthReportUseCase, private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, - private val breachCheckStateRepository: BreachCheckStateRepository, + private val healthSettingsRepository: HealthSettingsRepository, private val websiteHandler: WebsiteHandler, ) : ViewModel() { @@ -45,9 +45,12 @@ internal class PasswordHealthViewModel( private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) val uiState = combine( _base, - breachCheckStateRepository.observeBreachCheckState(), + healthSettingsRepository.observeBreachCheckState(), ) { base, breachCheckState -> - base.copy(breachCheckEnabled = breachCheckState.enabled) + base.copy( + breachCheckEnabled = breachCheckState.breachesEnabled, + notificationEnabled = breachCheckState.notificationsEnabled, + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -69,10 +72,14 @@ internal class PasswordHealthViewModel( ) is PasswordHealthUiEvent.OnBreachCheckChanged -> viewModelScope.launch { - breachCheckStateRepository.setBreachEnabled(event.enabled) + healthSettingsRepository.setBreachEnabled(event.enabled) if (event.enabled) runHealthCheck(RunPhase.FirstLoad) } + is PasswordHealthUiEvent.OnNotificationChanged -> viewModelScope.launch { + healthSettingsRepository.setNotificationEnabled(event.enabled) + } + is PasswordHealthUiEvent.FixClicked -> _base.update { it.copy(fixFlow = FixFlow.Generating(event.itemId)) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt index c51e3f799..2710a73ae 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -1,6 +1,9 @@ package de.davis.keygo.feature.password_health.presentation.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,21 +19,63 @@ import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.GapReason +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.segmentContainerColor @Composable -internal fun BreachCheck( +internal fun HealthSettings( state: PasswordHealthUiState, - onChange: (Boolean) -> Unit, + onEvent: (PasswordHealthUiEvent) -> Unit, modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + BreachCheck( + state = state, + onChange = { onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(it)) }, + shapes = ListItemDefaults.segmentedShapes(0, 2), + ) + + NotificationSwitch( + onChange = { onEvent(PasswordHealthUiEvent.OnNotificationChanged(it)) }, + shapes = ListItemDefaults.segmentedShapes(1, 2), + ) + } +} + +@Composable +private fun NotificationSwitch( + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + KeyGoSwitch( + checked = false, + onCheckedChange = onChange, + supportingContent = { + Text(text = "KeyGo will remind you of any security issues with your passwords.") + }, + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + shapes = shapes, + ) { + Text(text = "Enable Notifications") + } +} + +@Composable +private fun BreachCheck( + state: PasswordHealthUiState, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, ) { val message = state.breachGap?.message() KeyGoSwitch( checked = state.breachCheckEnabled, onCheckedChange = onChange, - modifier = modifier, supportingContent = { if (message == null) Text(text = stringResource(R.string.breach_check_opt_in_description)) @@ -38,7 +83,7 @@ internal fun BreachCheck( }, verticalAlignment = Alignment.CenterVertically, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), - shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large) + shapes = shapes, ) { Text(text = stringResource(R.string.breach_check_enable)) } @@ -62,9 +107,9 @@ private fun CheckGap.message(): String? { @Composable private fun BreachCheckPreview() { MaterialTheme { - BreachCheck( + HealthSettings( state = PasswordHealthUiState(), - onChange = {} + onEvent = {}, ) } } @@ -73,7 +118,7 @@ private fun BreachCheckPreview() { @Composable private fun BreachCheckUnavailablePreview() { MaterialTheme { - BreachCheck( + HealthSettings( state = PasswordHealthUiState( checkGaps = mapOf( CheckKind.Breach to CheckGap( @@ -82,7 +127,7 @@ private fun BreachCheckUnavailablePreview() { ), ), ), - onChange = {} + onEvent = {} ) } } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt index 9c235685c..b77c14655 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiEvent.kt @@ -9,6 +9,7 @@ internal sealed interface PasswordHealthUiEvent { data class ItemClicked(val itemId: ItemId) : PasswordHealthUiEvent data class OnBreachCheckChanged(val enabled: Boolean) : PasswordHealthUiEvent + data class OnNotificationChanged(val enabled: Boolean) : PasswordHealthUiEvent data class FixClicked(val itemId: ItemId) : PasswordHealthUiEvent data class PasswordGenerated(val password: String) : PasswordHealthUiEvent diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index aed48c631..2c87e2c3f 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -108,6 +108,7 @@ private const val MinimumGroupSize = 2 internal data class PasswordHealthUiState( val phase: RunPhase = RunPhase.Idle, val breachCheckEnabled: Boolean = false, + val notificationEnabled: Boolean = false, val totalPasswordCount: Int = 0, val reportedSections: List = emptyList(), val checkGaps: Map = emptyMap(), diff --git a/feature/password-health/src/main/proto/breach_check_state.proto b/feature/password-health/src/main/proto/health_settings.proto similarity index 57% rename from feature/password-health/src/main/proto/breach_check_state.proto rename to feature/password-health/src/main/proto/health_settings.proto index 58a37fb45..f32d084b7 100644 --- a/feature/password-health/src/main/proto/breach_check_state.proto +++ b/feature/password-health/src/main/proto/health_settings.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package de.davis.keygo.feature.password_health.data.local.model; option java_multiple_files = true; -message ProtoBreachCheckState { - bool enabled = 1; +message ProtoHealthSettings { + bool breachesEnabled = 1; + bool notificationsEnabled = 2; } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt deleted file mode 100644 index 5092b3e69..000000000 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeBreachCheckStateRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.davis.keygo.feature.password_health - -import de.davis.keygo.feature.password_health.domain.model.BreachCheckState -import de.davis.keygo.feature.password_health.domain.repository.BreachCheckStateRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -internal class FakeBreachCheckStateRepository(enabled: Boolean = true) : BreachCheckStateRepository { - - val state = MutableStateFlow(BreachCheckState(enabled = enabled)) - - override fun observeBreachCheckState(): Flow = state - - override suspend fun getBreachCheckState(): BreachCheckState = state.value - - override suspend fun setBreachEnabled(enabled: Boolean) { - state.value = BreachCheckState(enabled = enabled) - } -} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt new file mode 100644 index 000000000..e89d143fc --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthSettingsRepository.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +internal class FakeHealthSettingsRepository( + breachesEnabled: Boolean = true, + notificationsEnabled: Boolean = false, +) : HealthSettingsRepository { + + val state = MutableStateFlow( + HealthSettings( + breachesEnabled = breachesEnabled, + notificationsEnabled = notificationsEnabled, + ) + ) + + override fun observeBreachCheckState(): Flow = state + + override suspend fun getBreachCheckState(): HealthSettings = state.value + + override suspend fun setBreachEnabled(enabled: Boolean) { + state.update { it.copy(breachesEnabled = enabled) } + } + + override suspend fun setNotificationEnabled(enabled: Boolean) { + state.update { it.copy(notificationsEnabled = enabled) } + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt deleted file mode 100644 index 83343dc5b..000000000 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/BreachCheckStateRepositoryImplTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package de.davis.keygo.feature.password_health.data.repository - -import de.davis.keygo.feature.password_health.data.local.model.ProtoBreachCheckState -import de.davis.keygo.feature.password_health.domain.model.BreachCheckState -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class BreachCheckStateRepositoryImplTest { - - private val dataStore = FakeDataStore(ProtoBreachCheckState.getDefaultInstance()) - private val repository = BreachCheckStateRepositoryImpl(dataStore) - - @Test - fun theBreachCheckIsOffUntilTurnedOn() = runTest { - assertEquals(BreachCheckState(enabled = false), repository.getBreachCheckState()) - } - - @Test - fun turningItOnIsRemembered() = runTest { - repository.setBreachEnabled(true) - - assertEquals(BreachCheckState(enabled = true), repository.getBreachCheckState()) - assertEquals(true, dataStore.state.value.enabled) - } - - @Test - fun turningItOffAgainIsRemembered() = runTest { - repository.setBreachEnabled(true) - repository.setBreachEnabled(false) - - assertEquals(BreachCheckState(enabled = false), repository.getBreachCheckState()) - } - - @Test - fun observersSeeEveryChange() = runTest { - val seen = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - repository.observeBreachCheckState().take(3).toList(seen) - } - - repository.setBreachEnabled(true) - repository.setBreachEnabled(false) - - assertEquals( - listOf(false, true, false).map { BreachCheckState(enabled = it) }, - seen, - ) - } - - @Test - fun observingStartsFromTheStoredValue() = runTest { - dataStore.state.value = ProtoBreachCheckState.newBuilder().setEnabled(true).build() - - assertEquals(BreachCheckState(enabled = true), repository.observeBreachCheckState().first()) - } -} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt new file mode 100644 index 000000000..89829f5fa --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthSettingsRepositoryImplTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings +import de.davis.keygo.feature.password_health.domain.model.HealthSettings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class HealthSettingsRepositoryImplTest { + + private val dataStore = FakeDataStore(ProtoHealthSettings.getDefaultInstance()) + private val repository = HealthSettingsRepositoryImpl(dataStore) + + @Test + fun everythingIsOffUntilTurnedOn() = runTest { + assertEquals(settings(), repository.getBreachCheckState()) + } + + @Test + fun turningTheBreachCheckOnIsRemembered() = runTest { + repository.setBreachEnabled(true) + + assertEquals(settings(breaches = true), repository.getBreachCheckState()) + assertEquals(true, dataStore.state.value.breachesEnabled) + } + + @Test + fun turningTheBreachCheckOffAgainIsRemembered() = runTest { + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals(settings(), repository.getBreachCheckState()) + } + + @Test + fun turningNotificationsOnIsRemembered() = runTest { + repository.setNotificationEnabled(true) + + assertEquals(settings(notifications = true), repository.getBreachCheckState()) + assertEquals(true, dataStore.state.value.notificationsEnabled) + } + + @Test + fun eachSettingLeavesTheOtherAlone() = runTest { + repository.setBreachEnabled(true) + repository.setNotificationEnabled(true) + repository.setBreachEnabled(false) + + assertEquals(settings(notifications = true), repository.getBreachCheckState()) + } + + @Test + fun observersSeeEveryChange() = runTest { + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + repository.observeBreachCheckState().take(3).toList(seen) + } + + repository.setBreachEnabled(true) + repository.setBreachEnabled(false) + + assertEquals( + listOf(false, true, false).map { settings(breaches = it) }, + seen, + ) + } + + @Test + fun observingStartsFromTheStoredValue() = runTest { + dataStore.state.value = ProtoHealthSettings.newBuilder().setBreachesEnabled(true).build() + + assertEquals(settings(breaches = true), repository.observeBreachCheckState().first()) + } + + private fun settings(breaches: Boolean = false, notifications: Boolean = false) = + HealthSettings(breachesEnabled = breaches, notificationsEnabled = notifications) +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt index f878f8d34..80083a1b5 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt @@ -8,7 +8,7 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker @@ -45,7 +45,7 @@ class UnlockHealthScanTest { private val session = FakeSession() private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) - private val breachState = FakeBreachCheckStateRepository(enabled = false) + private val breachState = FakeHealthSettingsRepository(breachesEnabled = false) private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val attention = PasswordHealthAttention(session, appScope) @@ -110,7 +110,7 @@ class UnlockHealthScanTest { loginRepository = loginRepository, loginFingerprinter = LoginFingerprinterImpl(), healthReportStoreRepository = InMemoryStore(), - breachCheckStateRepository = breachState, + healthSettingsRepository = breachState, scanner = HealthReportScanner(FakeCryptographicScopeProvider(itemRepository), checkers), assembler = HealthReportAssembler(), attention = attention, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt index 99f7ba6bc..4ef687ff3 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheckTest.kt @@ -1,8 +1,8 @@ package de.davis.keygo.feature.password_health.domain.checker -import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.domain.model.BreachedError import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind @@ -25,7 +25,7 @@ import kotlin.test.assertTrue class BreachedPasswordCheckTest { private val breached = FakeBreachedRepository() - private val state = FakeBreachCheckStateRepository(enabled = true) + private val state = FakeHealthSettingsRepository(breachesEnabled = true) private val connectivity = FakeConnectivityRepository(online = true) private val checker = BreachedPasswordCheck(breached, state, connectivity) @@ -39,7 +39,12 @@ class BreachedPasswordCheckTest { checker.check(candidates("password")) assertEquals( - listOf(FakeBreachedRepository.Call("5BAA6", setOf("1E4C9B93F3F0682250B6CF8331B7EE68FD8"))), + listOf( + FakeBreachedRepository.Call( + "5BAA6", + setOf("1E4C9B93F3F0682250B6CF8331B7EE68FD8") + ) + ), breached.calls, ) } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt index 152c75532..14728473d 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -12,9 +12,9 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess -import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck @@ -69,7 +69,7 @@ class PasswordHealthReportUseCaseTest { private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) private val fingerprinter = LoginFingerprinterImpl() private val store = FakeHealthReportStore() - private val breachState = FakeBreachCheckStateRepository(enabled = true) + private val breachState = FakeHealthSettingsRepository(breachesEnabled = true) private val breached = FakeBreachedRepository() private val attention = PasswordHealthAttention( session = FakeSession(startUnlocked = true), @@ -86,7 +86,7 @@ class PasswordHealthReportUseCaseTest { loginRepository = loginRepository, loginFingerprinter = fingerprinter, healthReportStoreRepository = store, - breachCheckStateRepository = breachState, + healthSettingsRepository = breachState, scanner = HealthReportScanner(scopeProvider, listOf(strength, reuse, breach)), assembler = HealthReportAssembler(), attention = attention, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index 27a9f77a2..149b7fa1b 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -16,9 +16,9 @@ import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.view.domain.WebsiteHandler -import de.davis.keygo.feature.password_health.FakeBreachCheckStateRepository import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck @@ -68,7 +68,7 @@ class PasswordHealthViewModelTest { private val itemRepository = FakeItemRepository(loginRepository) private val scopeProvider = FakeCryptographicScopeProvider(itemRepository) private val vaultRepository = FakeVaultRepository() - private val breachState = FakeBreachCheckStateRepository(enabled = false) + private val breachState = FakeHealthSettingsRepository(breachesEnabled = false) private val breached = FakeBreachedRepository() private val websiteHandler = RecordingWebsiteHandler() @@ -93,7 +93,7 @@ class PasswordHealthViewModelTest { loginRepository = loginRepository, loginFingerprinter = LoginFingerprinterImpl(), healthReportStoreRepository = InMemoryStore(), - breachCheckStateRepository = breachState, + healthSettingsRepository = breachState, scanner = HealthReportScanner( scopeProvider, listOf( @@ -116,7 +116,7 @@ class PasswordHealthViewModelTest { passwordStrengthEstimator = FakePasswordStrengthEstimator(PasswordScore.Strong), totpService = FakeTotpService(), ), - breachCheckStateRepository = breachState, + healthSettingsRepository = breachState, websiteHandler = websiteHandler, ) @@ -232,7 +232,7 @@ class PasswordHealthViewModelTest { vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(true)) val state = vm.awaitIdle { it.totalPasswordCount == 1 } - assertEquals(true, breachState.state.value.enabled) + assertEquals(true, breachState.state.value.breachesEnabled) assertEquals(true, state.breachCheckEnabled) } @@ -244,7 +244,7 @@ class PasswordHealthViewModelTest { vm.onEvent(PasswordHealthUiEvent.OnBreachCheckChanged(false)) - assertEquals(false, breachState.state.value.enabled) + assertEquals(false, breachState.state.value.breachesEnabled) assertEquals(RunPhase.FirstLoad, vm.uiState.value.phase) assertEquals(0, vm.uiState.value.totalPasswordCount) } From c728f5dcc3776535b1dd60a5cafdcbcbd18d4db6 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 27 Sep 2026 02:09:52 +0200 Subject: [PATCH 39/54] feat: add notification support --- .../kotlin/de/davis/keygo/app/di/AppModule.kt | 10 +- .../feature/backup/di/FeatureBackupModule.kt | 5 - feature/password-health/build.gradle.kts | 3 + .../data/HealthCheckNotifierSchedulerImpl.kt | 37 ++++ .../password_health/data/NotifierImpl.kt | 19 +- .../mapper/HealthNotificationStateMapper.kt | 42 ++++ .../HealthNotificationStateRepositoryImpl.kt | 40 ++++ .../di/FeaturePasswordHealthModule.kt | 15 ++ .../HealthNotificationStateQualifier.kt | 6 + .../domain/HealthCheckNotifierScheduler.kt | 7 + .../password_health/domain/Notifier.kt | 1 + .../domain/model/HealthNotificationState.kt | 21 ++ .../domain/model/KeyGoNotification.kt | 13 +- .../domain/model/VaultFingerprint.kt | 41 ++++ .../HealthNotificationStateRepository.kt | 12 ++ .../usecase/PasswordHealthReportUseCase.kt | 43 +++- .../usecase/SendHealthReminderUseCase.kt | 88 ++++++++ .../usecase/SetHealthNotificationsUseCase.kt | 29 +++ .../presentation/PasswordHealthViewModel.kt | 6 +- .../presentation/component/BreachCheck.kt | 20 +- .../component/NotificationSwitch.kt | 184 +++++++++++++++++ .../worker/HealthCheckNotifier.kt | 24 +++ .../proto/health_notification_state.proto | 24 +++ .../src/main/res/values/strings.xml | 9 + .../FakeHealthCheckNotifierScheduler.kt | 17 ++ .../FakeHealthNotificationStateRepository.kt | 25 +++ .../feature/password_health/FakeNotifier.kt | 20 ++ ...althNotificationStateRepositoryImplTest.kt | 62 ++++++ .../domain/UnlockHealthScanTest.kt | 4 + .../domain/model/VaultFingerprintTest.kt | 27 +++ .../PasswordHealthReportUseCaseTest.kt | 62 ++++++ .../usecase/SendHealthReminderUseCaseTest.kt | 193 ++++++++++++++++++ .../SetHealthNotificationsUseCaseTest.kt | 48 +++++ .../PasswordHealthViewModelTest.kt | 14 ++ 34 files changed, 1125 insertions(+), 46 deletions(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt create mode 100644 feature/password-health/src/main/proto/health_notification_state.proto create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt diff --git a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt index 3ad2db030..4193c6a42 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt @@ -1,9 +1,12 @@ package de.davis.keygo.app.di +import android.content.Context +import androidx.work.WorkManager import de.davis.keygo.dashboard.di.DashboardModule import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module +import org.koin.core.annotation.Single @Module( includes = [ @@ -12,6 +15,11 @@ import org.koin.core.annotation.Module ) @ComponentScan("de.davis.keygo.app") @Configuration -object AppModule +object AppModule { + + @Single + internal fun provideWorkManager(context: Context): WorkManager = + WorkManager.getInstance(context) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt index 95ad98c46..ecad83d8f 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.backup.di import android.content.Context import androidx.datastore.dataStore -import androidx.work.WorkManager import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer import de.davis.keygo.feature.backup.data.local.model.ProtoBackupArkData import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobs @@ -43,8 +42,4 @@ object FeatureBackupModule { @BackupArkQualifier internal fun provideBackupArkDataStore(context: Context) = context.backupArkDataStore - - @Single - internal fun provideWorkManager(context: Context): WorkManager = - WorkManager.getInstance(context) } \ No newline at end of file diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index d0d61ffda..daf56b254 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -25,6 +25,9 @@ dependencies { implementation(libs.gosimple.nbvcxz) implementation(libs.androidx.datastore) + implementation(libs.androidx.work) + implementation(libs.koin.androidx.workmanager) + implementation(libs.com.google.accompanist.permissions) testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.item)) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt new file mode 100644 index 000000000..0ce611e39 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/HealthCheckNotifierSchedulerImpl.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.password_health.data + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.worker.HealthCheckNotifier +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration + +@Single +internal class HealthCheckNotifierSchedulerImpl( + private val workManager: WorkManager, +) : HealthCheckNotifierScheduler { + + override fun scheduleHealthReminder() { + // Delayed so turning reminders on does not immediately repeat the screen the user is on. + val request = PeriodicWorkRequestBuilder(REPEAT_INTERVAL) + .setInitialDelay(REPEAT_INTERVAL) + .build() + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = HealthCheckNotifier.UNIQUE_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = request, + ) + } + + override fun cancel() { + workManager.cancelUniqueWork(HealthCheckNotifier.UNIQUE_WORK_NAME) + } + + companion object { + private val REPEAT_INTERVAL = 7.days.toJavaDuration() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt index 0803fd065..c1920c775 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt @@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.Notifier import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification -import de.davis.keygo.feature.password_health.domain.model.isEmpty import org.koin.core.annotation.Single import de.davis.keygo.core.ui.R as CoreUiR @@ -38,14 +37,14 @@ internal class NotifierImpl( @Suppress("MissingPermission") override fun sendNotification(notification: KeyGoNotification) { - if (notification.isEmpty) { - notificationManagerCompat.cancel(notification.id) - return - } if (!canNotify()) return createChannels() - notificationManagerCompat.notify(notification.id, notification.buildNotification()) + notificationManagerCompat.notify(notification.kind.id, notification.buildNotification()) + } + + override fun cancel(kind: KeyGoNotification.Kind) { + notificationManagerCompat.cancel(kind.id) } private fun createChannels() { @@ -94,13 +93,13 @@ internal class NotifierImpl( } private val KeyGoNotification.channel: Channel - get() = when (this) { - is KeyGoNotification.NeedsAttention -> Channel.PasswordHealth + get() = when (kind) { + KeyGoNotification.Kind.NeedsAttention -> Channel.PasswordHealth } - private val KeyGoNotification.id: Int + private val KeyGoNotification.Kind.id: Int get() = when (this) { - is KeyGoNotification.NeedsAttention -> 1001 + KeyGoNotification.Kind.NeedsAttention -> 1001 } private enum class Channel( diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt new file mode 100644 index 000000000..e3f421f7f --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/mapper/HealthNotificationStateMapper.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.password_health.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReminder +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlin.time.Instant + +internal fun ProtoHealthNotificationState.toDomain() = HealthNotificationState( + snapshot = if (hasSnapshot()) snapshot.toDomain() else null, + reminder = if (hasReminder()) reminder.toDomain() else null, +) + +internal fun ProtoHealthSnapshot.toDomain() = HealthSnapshot( + attentionCount = attentionCount, + breachedCount = breachedCount, + vault = VaultFingerprint(vaultFingerprint.toByteArray()), +) + +internal fun ProtoHealthReminder.toDomain() = HealthReminder( + attentionCount = attentionCount, + breachedCount = breachedCount, + sentAt = Instant.fromEpochMilliseconds(sentAtEpochMillis), + repeats = repeats, +) + +internal fun HealthSnapshot.toProto(): ProtoHealthSnapshot = ProtoHealthSnapshot.newBuilder() + .setAttentionCount(attentionCount) + .setBreachedCount(breachedCount) + .setVaultFingerprint(vault.value.toByteString()) + .build() + +internal fun HealthReminder.toProto(): ProtoHealthReminder = ProtoHealthReminder.newBuilder() + .setAttentionCount(attentionCount) + .setBreachedCount(breachedCount) + .setSentAtEpochMillis(sentAt.toEpochMilliseconds()) + .setRepeats(repeats) + .build() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt new file mode 100644 index 000000000..5b79e7bbe --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImpl.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.password_health.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.data.mapper.toDomain +import de.davis.keygo.feature.password_health.data.mapper.toProto +import de.davis.keygo.feature.password_health.di.annotation.HealthNotificationStateQualifier +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class HealthNotificationStateRepositoryImpl( + @HealthNotificationStateQualifier + private val dataStore: DataStore, +) : HealthNotificationStateRepository { + + override suspend fun get(): HealthNotificationState = dataStore.data.first().toDomain() + + override suspend fun setSnapshot(snapshot: HealthSnapshot?) { + dataStore.updateData { + if (snapshot == null) it.toBuilder().clearSnapshot().build() + else it.toBuilder().setSnapshot(snapshot.toProto()).build() + } + } + + override suspend fun setReminder(reminder: HealthReminder?) { + dataStore.updateData { + if (reminder == null) it.toBuilder().clearReminder().build() + else it.toBuilder().setReminder(reminder.toProto()).build() + } + } + + override suspend fun clear() { + dataStore.updateData { ProtoHealthNotificationState.getDefaultInstance() } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index 034ba3fa0..d3a98ce0b 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -3,9 +3,11 @@ package de.davis.keygo.feature.password_health.di import android.content.Context import androidx.datastore.dataStore import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthSettings import de.davis.keygo.feature.password_health.di.annotation.BreachedQualifier +import de.davis.keygo.feature.password_health.di.annotation.HealthNotificationStateQualifier import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore import de.davis.keygo.feature.password_health.di.annotation.HealthSettingsQualifier import okhttp3.Dispatcher @@ -57,6 +59,14 @@ object FeaturePasswordHealthModule { ) ) + private val Context.healthNotificationStateDataStore by dataStore( + "health_notification_state.pb", + DefaultProtoSerializer( + defaultInstance = ProtoHealthNotificationState.getDefaultInstance(), + parser = ProtoHealthNotificationState.parser() + ) + ) + @Single @HealthSettingsQualifier internal fun provideHealthSettingsDataStore(context: Context) = @@ -67,6 +77,11 @@ object FeaturePasswordHealthModule { internal fun provideHealthReportStoreDataStore(context: Context) = context.healthReportStoreDataStore + @Single + @HealthNotificationStateQualifier + internal fun provideHealthNotificationStateDataStore(context: Context) = + context.healthNotificationStateDataStore + private fun Context.versionName(): String = packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt new file mode 100644 index 000000000..275325677 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/annotation/HealthNotificationStateQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.password_health.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class HealthNotificationStateQualifier diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt new file mode 100644 index 000000000..40b649f2c --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/HealthCheckNotifierScheduler.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.password_health.domain + +interface HealthCheckNotifierScheduler { + + fun scheduleHealthReminder() + fun cancel() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt index a6abc4ebb..117d5e524 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/Notifier.kt @@ -6,4 +6,5 @@ interface Notifier { fun canNotify(): Boolean fun sendNotification(notification: KeyGoNotification) + fun cancel(kind: KeyGoNotification.Kind) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt new file mode 100644 index 000000000..a911be078 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/HealthNotificationState.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.password_health.domain.model + +import kotlin.time.Instant + +data class HealthNotificationState( + val snapshot: HealthSnapshot? = null, + val reminder: HealthReminder? = null, +) + +data class HealthSnapshot( + val attentionCount: Int, + val breachedCount: Int, + val vault: VaultFingerprint, +) + +data class HealthReminder( + val attentionCount: Int, + val breachedCount: Int, + val sentAt: Instant, + val repeats: Int, +) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt index cf0471e68..4a6b04ff9 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/KeyGoNotification.kt @@ -1,10 +1,13 @@ package de.davis.keygo.feature.password_health.domain.model sealed interface KeyGoNotification { - data class NeedsAttention(val count: Int) : KeyGoNotification -} + val kind: Kind + + data class NeedsAttention(val count: Int) : KeyGoNotification { + override val kind = Kind.NeedsAttention + } -val KeyGoNotification.isEmpty: Boolean - get() = when (this) { - is KeyGoNotification.NeedsAttention -> count <= 0 + enum class Kind { + NeedsAttention, } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt new file mode 100644 index 000000000..015f648df --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprint.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.feature.password_health.domain.model + +import java.security.MessageDigest + +class VaultFingerprint(val value: ByteArray) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VaultFingerprint + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int = value.contentHashCode() + + companion object { + + fun of(fingerprints: Collection): VaultFingerprint { + val digest = MessageDigest.getInstance("SHA-256") + fingerprints + .map { it.value } + .sortedWith(UnsignedBytes) + .forEach(digest::update) + + return VaultFingerprint(digest.digest()) + } + + // Arrays.compareUnsigned would do, but only exists from API 33. + private object UnsignedBytes : Comparator { + override fun compare(a: ByteArray, b: ByteArray): Int { + for (i in 0 until minOf(a.size, b.size)) { + val diff = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (diff != 0) return diff + } + return a.size - b.size + } + } + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt new file mode 100644 index 000000000..360e4bb79 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/HealthNotificationStateRepository.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.password_health.domain.repository + +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot + +interface HealthNotificationStateRepository { + suspend fun get(): HealthNotificationState + suspend fun setSnapshot(snapshot: HealthSnapshot?) + suspend fun setReminder(reminder: HealthReminder?) + suspend fun clear() +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt index 34a853fd8..85f5fbc2a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCase.kt @@ -5,15 +5,20 @@ import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.mapSuccess import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler import de.davis.keygo.feature.password_health.domain.LoginFingerprinter import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import kotlinx.coroutines.Dispatchers @@ -36,6 +41,8 @@ class PasswordHealthReportUseCase( private val scanner: HealthReportScanner, private val assembler: HealthReportAssembler, private val attention: PasswordHealthAttention, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val healthCheckNotifierScheduler: HealthCheckNotifierScheduler, ) { private val mutex = Mutex() @@ -45,14 +52,31 @@ class PasswordHealthReportUseCase( coroutineContext: CoroutineContext = Dispatchers.Default, ): Result = mutex.withLock { report(force, coroutineContext) - .onSuccess { attention.update(it.needsAttentionCount) } - .onFailure { if (it == PasswordHealthReportError.NoPasswords) attention.clear() } + .onSuccess { (report, vault) -> + attention.update(report.needsAttentionCount) + recordSnapshot(report.snapshot(vault)) + } + .onFailure { + if (it == PasswordHealthReportError.NoPasswords) { + attention.clear() + recordSnapshot(null) + } + } + .mapSuccess { it.report } + } + + private suspend fun recordSnapshot(snapshot: HealthSnapshot?) { + if (!healthSettingsRepository.getBreachCheckState().notificationsEnabled) return + + healthNotificationStateRepository.setSnapshot(snapshot) + // The setting outlives the schedule across a device restore; re-arming here brings it back. + healthCheckNotifierScheduler.scheduleHealthReminder() } private suspend fun report( force: Boolean, coroutineContext: CoroutineContext, - ): Result = resultBinding { + ): Result = resultBinding { withContext(coroutineContext) { val breachCheckEnabled = async { healthSettingsRepository.getBreachCheckState().breachesEnabled @@ -74,10 +98,11 @@ class PasswordHealthReportUseCase( .filterNotNull() .toMap() + val vault = VaultFingerprint.of(fingerprints.values) val now = Clock.System.now() val previous = storedReport.await() previous?.takeIf { it.isFreshFor(fingerprints, breachCheckEnabled.await(), now) } - ?.let { return@withContext assembler.assemble(it, logins) } + ?.let { return@withContext Scan(assembler.assemble(it, logins), vault) } val report = scanner.scan( logins = logins, @@ -91,10 +116,18 @@ class PasswordHealthReportUseCase( healthReportStoreRepository.storeReport(report).onFailure { Log.w(TAG, "Failed to store password health report: $it") } - assembler.assemble(report, logins) + Scan(assembler.assemble(report, logins), vault) } } + private data class Scan(val report: PasswordHealthReport, val vault: VaultFingerprint) + + private fun PasswordHealthReport.snapshot(vault: VaultFingerprint) = HealthSnapshot( + attentionCount = needsAttentionCount, + breachedCount = (groups.flatMap { it.members } + standalone).count { it.breach != null }, + vault = vault, + ) + companion object { private const val TAG = "PasswordHealthReportUseCase" } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt new file mode 100644 index 000000000..59061e4ab --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCase.kt @@ -0,0 +1,88 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.feature.password_health.domain.LoginFingerprinter +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +@Single +class SendHealthReminderUseCase( + private val loginRepository: LoginRepository, + private val loginFingerprinter: LoginFingerprinter, + private val healthSettingsRepository: HealthSettingsRepository, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val notifier: Notifier, +) { + + suspend operator fun invoke(now: Instant = Clock.System.now()) { + if (!healthSettingsRepository.getBreachCheckState().notificationsEnabled) return + if (!notifier.canNotify()) return + + val state = healthNotificationStateRepository.get() + val snapshot = state.snapshot + if (snapshot == null || snapshot.attentionCount == 0) return withdraw() + + // Edited passwords would make the counts lie; the next unlock takes a new snapshot. + if (snapshot.vault != currentVault()) return + + val last = state.reminder + when { + last == null || snapshot.isWorseThan(last) -> notify(snapshot, now, repeats = 0) + last.isDue(now) -> notify(snapshot, now, repeats = last.repeats + 1) + else -> lowerBaseline(last, snapshot) + } + } + + private suspend fun notify(snapshot: HealthSnapshot, now: Instant, repeats: Int) { + notifier.sendNotification(KeyGoNotification.NeedsAttention(count = snapshot.attentionCount)) + healthNotificationStateRepository.setReminder( + HealthReminder( + attentionCount = snapshot.attentionCount, + breachedCount = snapshot.breachedCount, + sentAt = now, + repeats = repeats, + ), + ) + } + + private suspend fun withdraw() { + notifier.cancel(KeyGoNotification.Kind.NeedsAttention) + healthNotificationStateRepository.setReminder(null) + } + + // Issues were fixed since the last notification. Comparing against the old, higher counts + // would hide a new issue that only brings the total back up, so compare against today's. + // The reminder clock is kept: fixing things must not earn the user an extra notification. + private suspend fun lowerBaseline(last: HealthReminder, snapshot: HealthSnapshot) { + val lowered = last.copy( + attentionCount = snapshot.attentionCount, + breachedCount = snapshot.breachedCount, + ) + if (lowered != last) healthNotificationStateRepository.setReminder(lowered) + } + + private suspend fun currentVault(): VaultFingerprint = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { loginFingerprinter.fingerprint(it) }, + ) + + private fun HealthSnapshot.isWorseThan(reminder: HealthReminder) = + attentionCount > reminder.attentionCount || breachedCount > reminder.breachedCount + + private fun HealthReminder.isDue(now: Instant) = + repeats < MAX_REPEATS && now - sentAt >= REMIND_AFTER + + companion object { + val REMIND_AFTER = 30.days + const val MAX_REPEATS = 2 + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt new file mode 100644 index 000000000..0ded9299e --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCase.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository +import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository +import org.koin.core.annotation.Single + +@Single +class SetHealthNotificationsUseCase( + private val healthSettingsRepository: HealthSettingsRepository, + private val healthNotificationStateRepository: HealthNotificationStateRepository, + private val scheduler: HealthCheckNotifierScheduler, + private val notifier: Notifier, +) { + + suspend operator fun invoke(enabled: Boolean) { + healthSettingsRepository.setNotificationEnabled(enabled) + if (enabled) scheduler.scheduleHealthReminder() + else turnOff() + } + + private suspend fun turnOff() { + scheduler.cancel() + healthNotificationStateRepository.clear() + notifier.cancel(KeyGoNotification.Kind.NeedsAttention) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index d4f6f7246..007e0e7b5 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -14,6 +14,7 @@ import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent @@ -36,6 +37,7 @@ internal class PasswordHealthViewModel( private val passwordHealth: PasswordHealthReportUseCase, private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, private val healthSettingsRepository: HealthSettingsRepository, + private val setHealthNotifications: SetHealthNotificationsUseCase, private val websiteHandler: WebsiteHandler, ) : ViewModel() { @@ -77,7 +79,9 @@ internal class PasswordHealthViewModel( } is PasswordHealthUiEvent.OnNotificationChanged -> viewModelScope.launch { - healthSettingsRepository.setNotificationEnabled(event.enabled) + setHealthNotifications(event.enabled) + // Takes the first snapshot for the reminder worker; the report is usually cached. + if (event.enabled) passwordHealth(force = false) } is PasswordHealthUiEvent.FixClicked -> diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt index 2710a73ae..cc41a2108 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -40,31 +40,13 @@ internal fun HealthSettings( ) NotificationSwitch( + checked = state.notificationEnabled, onChange = { onEvent(PasswordHealthUiEvent.OnNotificationChanged(it)) }, shapes = ListItemDefaults.segmentedShapes(1, 2), ) } } -@Composable -private fun NotificationSwitch( - onChange: (Boolean) -> Unit, - shapes: ListItemShapes, -) { - KeyGoSwitch( - checked = false, - onCheckedChange = onChange, - supportingContent = { - Text(text = "KeyGo will remind you of any security issues with your passwords.") - }, - verticalAlignment = Alignment.CenterVertically, - colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), - shapes = shapes, - ) { - Text(text = "Enable Notifications") - } -} - @Composable private fun BreachCheck( state: PasswordHealthUiState, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt new file mode 100644 index 000000000..e7ba377fc --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NotificationSwitch.kt @@ -0,0 +1,184 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import de.davis.keygo.core.security.presentation.rememberHandoffStarter +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.segmentContainerColor + +private const val TAG = "NotificationSwitch" + +private enum class PermissionDialog { Rationale, OpenSettings } + +@Composable +internal fun NotificationSwitch( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + // Below API 33 the permission does not exist, so Accompanist would always report it denied. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionGatedSwitch(checked, onChange, shapes) + else NotificationSwitchContent(checked, onChange, shapes) +} + +@OptIn(ExperimentalPermissionsApi::class) +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +private fun PermissionGatedSwitch( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + var dialog by remember { mutableStateOf(null) } + var enableWhenGranted by remember { mutableStateOf(false) } + var asked by rememberSaveable { mutableStateOf(false) } + val permission = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) { granted -> + if (granted) onChange(true) + asked = true + } + + // Accompanist re-reads the status on resume, which is how a grant made in settings shows up. + LaunchedEffect(permission.status.isGranted) { + if (permission.status.isGranted && enableWhenGranted) onChange(true) + enableWhenGranted = false + } + + NotificationSwitchContent( + checked = checked && permission.status.isGranted, + onChange = { enabled -> + when { + !enabled || permission.status.isGranted -> onChange(enabled) + permission.status.shouldShowRationale -> dialog = PermissionDialog.Rationale + asked -> dialog = PermissionDialog.OpenSettings + else -> permission.launchPermissionRequest() + } + }, + shapes = shapes, + ) + + when (dialog) { + PermissionDialog.Rationale -> PermissionDialog( + text = R.string.notification_permission_rationale, + confirm = R.string.notification_permission_grant, + onConfirm = { + dialog = null + asked = true + permission.launchPermissionRequest() + }, + onDismiss = { dialog = null }, + ) + + PermissionDialog.OpenSettings -> OpenSettingsDialog( + onOpen = { + dialog = null + enableWhenGranted = true + }, + onDismiss = { dialog = null }, + ) + + null -> Unit + } +} + +@Composable +private fun OpenSettingsDialog( + onOpen: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val openSystemScreen = rememberHandoffStarter() + + PermissionDialog( + text = R.string.notification_permission_denied_permanently, + confirm = R.string.notification_permission_open_settings, + onConfirm = { + onOpen() + openSystemScreen.launch( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ), + ).onFailure { + Log.w(TAG, "No activity found to handle the app details settings", it) + } + }, + onDismiss = onDismiss, + ) +} + +@Composable +private fun PermissionDialog( + @StringRes text: Int, + @StringRes confirm: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = onConfirm) { + Text(text = stringResource(confirm)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(text = stringResource(R.string.notification_permission_cancel)) + } + }, + title = { Text(text = stringResource(R.string.notification_permission_title)) }, + text = { Text(text = stringResource(text)) }, + icon = { Icon(imageVector = Icons.Default.Notifications, contentDescription = null) }, + ) +} + +@Composable +private fun NotificationSwitchContent( + checked: Boolean, + onChange: (Boolean) -> Unit, + shapes: ListItemShapes, +) { + KeyGoSwitch( + checked = checked, + onCheckedChange = onChange, + supportingContent = { + Text(text = stringResource(R.string.health_notifications_description)) + }, + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + shapes = shapes, + ) { + Text(text = stringResource(R.string.health_notifications_enable)) + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt new file mode 100644 index 000000000..1ec040acd --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/worker/HealthCheckNotifier.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.password_health.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase +import org.koin.android.annotation.KoinWorker + +@KoinWorker +internal class HealthCheckNotifier( + appContext: Context, + params: WorkerParameters, + private val sendHealthReminder: SendHealthReminderUseCase, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + sendHealthReminder() + return Result.success() + } + + companion object { + const val UNIQUE_WORK_NAME = "health-check-notifier" + } +} diff --git a/feature/password-health/src/main/proto/health_notification_state.proto b/feature/password-health/src/main/proto/health_notification_state.proto new file mode 100644 index 000000000..b6152e239 --- /dev/null +++ b/feature/password-health/src/main/proto/health_notification_state.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.password_health.data.local.model; +option java_multiple_files = true; + +// Deliberately unencrypted: the reminder worker runs without a session. Only counts and a digest +// over password ciphertexts, never which items are affected. +message ProtoHealthNotificationState { + ProtoHealthSnapshot snapshot = 1; + ProtoHealthReminder reminder = 2; +} + +message ProtoHealthSnapshot { + int32 attention_count = 1; + int32 breached_count = 2; + bytes vault_fingerprint = 3; +} + +message ProtoHealthReminder { + int32 attention_count = 1; + int32 breached_count = 2; + int64 sent_at_epoch_millis = 3; + int32 repeats = 4; +} diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 44f4074f1..e3ed1e489 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -73,6 +73,15 @@ Pick an item to see it here + Enable notifications + KeyGo will remind you of any security issues with your passwords. + Notification permission + KeyGo needs permission to send notifications to remind you about passwords that need attention. + Notifications are turned off for KeyGo. To get password reminders, allow them in your app settings. + Grant + Open settings + Cancel + Password health Reminders when some of your passwords need attention diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt new file mode 100644 index 000000000..cbb1b1e62 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthCheckNotifierScheduler.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.HealthCheckNotifierScheduler + +internal class FakeHealthCheckNotifierScheduler : HealthCheckNotifierScheduler { + + var scheduled = false + private set + + override fun scheduleHealthReminder() { + scheduled = true + } + + override fun cancel() { + scheduled = false + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt new file mode 100644 index 000000000..ab5966325 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeHealthNotificationStateRepository.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.repository.HealthNotificationStateRepository + +internal class FakeHealthNotificationStateRepository( + var state: HealthNotificationState = HealthNotificationState(), +) : HealthNotificationStateRepository { + + override suspend fun get(): HealthNotificationState = state + + override suspend fun setSnapshot(snapshot: HealthSnapshot?) { + state = state.copy(snapshot = snapshot) + } + + override suspend fun setReminder(reminder: HealthReminder?) { + state = state.copy(reminder = reminder) + } + + override suspend fun clear() { + state = HealthNotificationState() + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt new file mode 100644 index 000000000..b21919bcb --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeNotifier.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.password_health + +import de.davis.keygo.feature.password_health.domain.Notifier +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification + +internal class FakeNotifier(var canNotify: Boolean = true) : Notifier { + + val sent = mutableListOf() + val cancelled = mutableListOf() + + override fun canNotify(): Boolean = canNotify + + override fun sendNotification(notification: KeyGoNotification) { + sent += notification + } + + override fun cancel(kind: KeyGoNotification.Kind) { + cancelled += kind + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt new file mode 100644 index 000000000..468475b9d --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthNotificationStateRepositoryImplTest.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.password_health.data.repository + +import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class HealthNotificationStateRepositoryImplTest { + + private val dataStore = FakeDataStore(ProtoHealthNotificationState.getDefaultInstance()) + private val repository = HealthNotificationStateRepositoryImpl(dataStore) + + private val snapshot = HealthSnapshot(3, 1, VaultFingerprint(byteArrayOf(1, 2, 3))) + private val reminder = HealthReminder(3, 1, Instant.fromEpochMilliseconds(42), repeats = 1) + + @Test + fun startsEmpty() = runTest { + assertEquals(HealthNotificationState(), repository.get()) + } + + @Test + fun snapshotAndReminderRoundTrip() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + assertEquals(HealthNotificationState(snapshot, reminder), repository.get()) + } + + @Test + fun anEmptySnapshotIsStillASnapshot() = runTest { + val empty = HealthSnapshot(0, 0, VaultFingerprint.of(emptyList())) + + repository.setSnapshot(empty) + + assertEquals(empty, repository.get().snapshot) + } + + @Test + fun nullRemovesOnlyThatPart() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + repository.setReminder(null) + + assertEquals(HealthNotificationState(snapshot = snapshot), repository.get()) + } + + @Test + fun clearForgetsEverything() = runTest { + repository.setSnapshot(snapshot) + repository.setReminder(reminder) + + repository.clear() + + assertEquals(HealthNotificationState(), repository.get()) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt index 80083a1b5..014da2016 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/UnlockHealthScanTest.kt @@ -8,6 +8,8 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.checker.PasswordHealthChecker @@ -114,6 +116,8 @@ class UnlockHealthScanTest { scanner = HealthReportScanner(FakeCryptographicScopeProvider(itemRepository), checkers), assembler = HealthReportAssembler(), attention = attention, + healthNotificationStateRepository = FakeHealthNotificationStateRepository(), + healthCheckNotifierScheduler = FakeHealthCheckNotifierScheduler(), ), attention = attention, appScope = appScope, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt new file mode 100644 index 000000000..eda9a3d27 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/model/VaultFingerprintTest.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.password_health.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class VaultFingerprintTest { + + private val a = HealthFingerprint(byteArrayOf(1)) + private val b = HealthFingerprint(byteArrayOf(-1)) + private val c = HealthFingerprint(byteArrayOf(2)) + + @Test + fun orderDoesNotMatter() { + assertEquals(VaultFingerprint.of(listOf(a, b, c)), VaultFingerprint.of(listOf(c, a, b))) + } + + @Test + fun aChangedPasswordChangesIt() { + assertNotEquals(VaultFingerprint.of(listOf(a, b)), VaultFingerprint.of(listOf(a, c))) + } + + @Test + fun anAddedPasswordChangesIt() { + assertNotEquals(VaultFingerprint.of(listOf(a, b)), VaultFingerprint.of(listOf(a, b, c))) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt index 14728473d..bd68e542a 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/PasswordHealthReportUseCaseTest.kt @@ -14,6 +14,8 @@ import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention @@ -28,12 +30,14 @@ import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.CheckOutcome import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot import de.davis.keygo.feature.password_health.domain.model.ItemIssue import de.davis.keygo.feature.password_health.domain.model.PasswordCandidate import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.model.RelationType import de.davis.keygo.feature.password_health.domain.model.StoredHealthItem import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint import de.davis.keygo.feature.password_health.domain.report.HealthReportAssembler import de.davis.keygo.feature.password_health.domain.report.HealthReportScanner import de.davis.keygo.feature.password_health.domain.report.VAULT_ID @@ -44,6 +48,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith @@ -54,6 +59,7 @@ import java.util.Collections import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -71,6 +77,8 @@ class PasswordHealthReportUseCaseTest { private val store = FakeHealthReportStore() private val breachState = FakeHealthSettingsRepository(breachesEnabled = true) private val breached = FakeBreachedRepository() + private val notificationState = FakeHealthNotificationStateRepository() + private val scheduler = FakeHealthCheckNotifierScheduler() private val attention = PasswordHealthAttention( session = FakeSession(startUnlocked = true), appScope = CoroutineScope(Dispatchers.Unconfined), @@ -90,6 +98,8 @@ class PasswordHealthReportUseCaseTest { scanner = HealthReportScanner(scopeProvider, listOf(strength, reuse, breach)), assembler = HealthReportAssembler(), attention = attention, + healthNotificationStateRepository = notificationState, + healthCheckNotifierScheduler = scheduler, ) @Test @@ -193,6 +203,54 @@ class PasswordHealthReportUseCaseTest { assertEquals(listOf(ItemIssue.Breached(3)), item.issues) } + @Test + fun aSnapshotIsRecordedForTheReminderWhenItIsOn() = runTest { + breachState.setNotificationEnabled(true) + seed(0, password = "password") + seed(1, score = PasswordScore.Weak) + breached.breaches = mapOf("5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" to 3) + + run().assertSuccess() + + val snapshot = assertNotNull(notificationState.state.snapshot) + assertEquals(2, snapshot.attentionCount) + assertEquals(1, snapshot.breachedCount) + assertEquals(currentVault(), snapshot.vault) + assertTrue(scheduler.scheduled) + } + + @Test + fun aCachedReportAlsoRecordsASnapshot() = runTest { + seed(0, score = PasswordScore.Weak) + run().assertSuccess() + breachState.setNotificationEnabled(true) + + run().assertSuccess() + + assertEquals(1, assertNotNull(notificationState.state.snapshot).attentionCount) + assertEquals(1, store.writes) + } + + @Test + fun noSnapshotIsRecordedWhileTheReminderIsOff() = runTest { + seed(0, score = PasswordScore.Weak) + + run().assertSuccess() + + assertNull(notificationState.state.snapshot) + assertFalse(scheduler.scheduled) + } + + @Test + fun anEmptyVaultClearsTheSnapshot() = runTest { + breachState.setNotificationEnabled(true) + notificationState.setSnapshot(HealthSnapshot(3, 0, VaultFingerprint(byteArrayOf(1)))) + + run().assertFailure() + + assertNull(notificationState.state.snapshot) + } + @Test fun aFreshStoredReportIsServedWithoutScanning() = runTest { seed(0, 1, score = PasswordScore.Weak) @@ -378,6 +436,10 @@ class PasswordHealthReportUseCaseTest { private suspend fun run(force: Boolean = false) = useCase(force, EmptyCoroutineContext) + private suspend fun currentVault() = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { fingerprinter.fingerprint(it) }, + ) + /** Seeds logins whose passwords the fake provider can decrypt. */ private fun seed( vararg ns: Int, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt new file mode 100644 index 000000000..c8db94d95 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SendHealthReminderUseCaseTest.kt @@ -0,0 +1,193 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier +import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthReminder +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import de.davis.keygo.feature.password_health.domain.report.id +import de.davis.keygo.feature.password_health.domain.report.login +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase.Companion.MAX_REPEATS +import de.davis.keygo.feature.password_health.domain.usecase.SendHealthReminderUseCase.Companion.REMIND_AFTER +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class SendHealthReminderUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val fingerprinter = LoginFingerprinterImpl() + private val settings = FakeHealthSettingsRepository(notificationsEnabled = true) + private val state = FakeHealthNotificationStateRepository() + private val notifier = FakeNotifier() + + private val useCase = SendHealthReminderUseCase( + loginRepository = loginRepository, + loginFingerprinter = fingerprinter, + healthSettingsRepository = settings, + healthNotificationStateRepository = state, + notifier = notifier, + ) + + @Test + fun theFirstSnapshotWithIssuesIsAnnounced() = runTest { + snapshot(attention = 3) + + useCase(NOW) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + assertEquals(reminder(attention = 3, at = NOW), state.state.reminder) + } + + @Test + fun theSameIssuesAreNotRepeatedBeforeTheReminderIsDue() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + REMIND_AFTER - 1.days) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun theSameIssuesAreRemindedOnceTheReminderIsDue() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW)) + val later = NOW + REMIND_AFTER + + useCase(later) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + assertEquals(reminder(attention = 3, at = later, repeats = 1), state.state.reminder) + } + + @Test + fun remindersStopAfterTheLimit() = runTest { + snapshot(attention = 3, reminder = reminder(attention = 3, at = NOW, repeats = MAX_REPEATS)) + + useCase(NOW + REMIND_AFTER * 3) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun moreIssuesAreAnnouncedRightAwayAndRestartTheReminders() = runTest { + snapshot(attention = 4, reminder = reminder(attention = 3, at = NOW, repeats = MAX_REPEATS)) + val later = NOW + 1.days + + useCase(later) + + assertEquals(listOf(needsAttention(4)), notifier.sent) + assertEquals(reminder(attention = 4, at = later), state.state.reminder) + } + + @Test + fun aNewBreachIsAnnouncedEvenWhenTheTotalStaysTheSame() = runTest { + snapshot(attention = 3, breached = 1, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertEquals(listOf(needsAttention(3)), notifier.sent) + } + + @Test + fun fewerIssuesAreRememberedQuietly() = runTest { + snapshot(attention = 2, reminder = reminder(attention = 3, at = NOW, repeats = 1)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(reminder(attention = 2, at = NOW, repeats = 1), state.state.reminder) + } + + @Test + fun aFixedVaultWithdrawsTheNotification() = runTest { + snapshot(attention = 0, reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + assertNull(state.state.reminder) + } + + @Test + fun aSnapshotOfAnEditedVaultIsIgnored() = runTest { + snapshot(attention = 3) + loginRepository.seed(login(id(9))) + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + assertNull(state.state.reminder) + } + + @Test + fun withoutASnapshotTheNotificationIsWithdrawn() = runTest { + state.state = HealthNotificationState(reminder = reminder(attention = 3, at = NOW)) + + useCase(NOW + 1.days) + + assertTrue(notifier.sent.isEmpty()) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + assertNull(state.state.reminder) + } + + @Test + fun nothingHappensWhenRemindersAreOff() = runTest { + snapshot(attention = 3) + settings.setNotificationEnabled(false) + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + } + + @Test + fun aBlockedNotificationIsNotCountedAsSent() = runTest { + snapshot(attention = 3) + notifier.canNotify = false + + useCase(NOW) + + assertTrue(notifier.sent.isEmpty()) + assertNull(state.state.reminder) + } + + private suspend fun snapshot( + attention: Int, + breached: Int = 0, + reminder: HealthReminder? = null, + ) { + loginRepository.seed(login(id(0)), login(id(1))) + val vault = VaultFingerprint.of( + loginRepository.observeLogins().first().mapNotNull { fingerprinter.fingerprint(it) }, + ) + state.state = HealthNotificationState( + snapshot = HealthSnapshot(attention, breached, vault), + reminder = reminder, + ) + } + + private fun reminder(attention: Int, at: Instant, repeats: Int = 0) = HealthReminder( + attentionCount = attention, + breachedCount = 0, + sentAt = at, + repeats = repeats, + ) + + private fun needsAttention(count: Int): KeyGoNotification = + KeyGoNotification.NeedsAttention(count) + + private companion object { + val NOW = Instant.fromEpochSeconds(1_800_000_000) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt new file mode 100644 index 000000000..d07ef377a --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/domain/usecase/SetHealthNotificationsUseCaseTest.kt @@ -0,0 +1,48 @@ +package de.davis.keygo.feature.password_health.domain.usecase + +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository +import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier +import de.davis.keygo.feature.password_health.domain.model.HealthNotificationState +import de.davis.keygo.feature.password_health.domain.model.HealthSnapshot +import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.domain.model.VaultFingerprint +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SetHealthNotificationsUseCaseTest { + + private val settings = FakeHealthSettingsRepository(notificationsEnabled = false) + private val state = FakeHealthNotificationStateRepository( + HealthNotificationState(snapshot = HealthSnapshot(3, 0, VaultFingerprint(byteArrayOf(1)))), + ) + private val scheduler = FakeHealthCheckNotifierScheduler() + private val notifier = FakeNotifier() + + private val useCase = SetHealthNotificationsUseCase(settings, state, scheduler, notifier) + + @Test + fun turningOnSchedulesTheReminder() = runTest { + useCase(true) + + assertTrue(settings.state.value.notificationsEnabled) + assertTrue(scheduler.scheduled) + } + + @Test + fun turningOffCancelsAndForgetsEverything() = runTest { + useCase(true) + + useCase(false) + + assertFalse(settings.state.value.notificationsEnabled) + assertFalse(scheduler.scheduled) + assertEquals(HealthNotificationState(), state.state) + assertEquals(listOf(KeyGoNotification.Kind.NeedsAttention), notifier.cancelled) + } + +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index 149b7fa1b..af3023d7e 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -18,7 +18,10 @@ import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUse import de.davis.keygo.feature.item.view.domain.WebsiteHandler import de.davis.keygo.feature.password_health.FakeBreachedRepository import de.davis.keygo.feature.password_health.FakeConnectivityRepository +import de.davis.keygo.feature.password_health.FakeHealthCheckNotifierScheduler +import de.davis.keygo.feature.password_health.FakeHealthNotificationStateRepository import de.davis.keygo.feature.password_health.FakeHealthSettingsRepository +import de.davis.keygo.feature.password_health.FakeNotifier import de.davis.keygo.feature.password_health.data.LoginFingerprinterImpl import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordCheck @@ -36,6 +39,7 @@ import de.davis.keygo.feature.password_health.domain.report.id import de.davis.keygo.feature.password_health.domain.report.login import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase +import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus @@ -71,6 +75,8 @@ class PasswordHealthViewModelTest { private val breachState = FakeHealthSettingsRepository(breachesEnabled = false) private val breached = FakeBreachedRepository() private val websiteHandler = RecordingWebsiteHandler() + private val notificationState = FakeHealthNotificationStateRepository() + private val scheduler = FakeHealthCheckNotifierScheduler() @BeforeTest fun setUp() { @@ -107,6 +113,8 @@ class PasswordHealthViewModelTest { FakeSession(), CoroutineScope(Dispatchers.Unconfined) ), + healthNotificationStateRepository = notificationState, + healthCheckNotifierScheduler = scheduler, ), createNewOrUpdateLogin = CreateNewOrUpdateLoginUseCase( cryptographicScopeProvider = scopeProvider, @@ -117,6 +125,12 @@ class PasswordHealthViewModelTest { totpService = FakeTotpService(), ), healthSettingsRepository = breachState, + setHealthNotifications = SetHealthNotificationsUseCase( + healthSettingsRepository = breachState, + healthNotificationStateRepository = notificationState, + scheduler = scheduler, + notifier = FakeNotifier(), + ), websiteHandler = websiteHandler, ) From a82406157dba9c9c7b03da58295d1a790927ab08 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 27 Sep 2026 12:55:49 +0200 Subject: [PATCH 40/54] feat: improve notification icon --- core/ui/src/main/res/drawable/ic_app.xml | 30 +++++++++++++++++++ .../password_health/data/NotifierImpl.kt | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 core/ui/src/main/res/drawable/ic_app.xml diff --git a/core/ui/src/main/res/drawable/ic_app.xml b/core/ui/src/main/res/drawable/ic_app.xml new file mode 100644 index 000000000..edb6c953a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_app.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt index c1920c775..fdd6cf48a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt @@ -77,7 +77,7 @@ internal class NotifierImpl( private fun KeyGoNotification.baseNotification() = NotificationCompat.Builder(context, channel.id) - .setSmallIcon(CoreUiR.drawable.ic_launcher_monochrome) + .setSmallIcon(CoreUiR.drawable.ic_app) .setContentTitle(context.getString(R.string.password_health_title)) // TODO: send user to correct screen From 294781a1a7d44da0acb21d62adb17873e416ac4b Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 27 Sep 2026 14:14:19 +0200 Subject: [PATCH 41/54] feat: open health screen when notification tapped --- .../keygo/app/presentation/AppDestinations.kt | 13 +++++-- .../keygo/app/presentation/AppViewModel.kt | 13 +++++++ .../keygo/app/presentation/MainActivity.kt | 19 +++++++++++ .../presentation/navigation/AppNavigator.kt | 9 +++++ .../app/presentation/AppDestinationsTest.kt | 27 +++++++++++++++ .../navigation/AppNavigatorTest.kt | 34 +++++++++++++++++++ .../password_health/data/NotifierImpl.kt | 16 ++++++--- .../presentation/PasswordHealthEntries.kt | 2 ++ 8 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt 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 3896accb4..b2a07fb38 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 @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.NavKey import de.davis.keygo.R import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute import de.davis.keygo.feature.settings.presentation.SettingsRoute @@ -18,14 +19,16 @@ enum class AppDestinations( val route: NavKey, @StringRes val label: Int, val icon: ImageVector, - @StringRes val contentDescription: Int + @StringRes val contentDescription: Int, + val intentAction: String? = null, ) { HOME(RouteDestination.Home, R.string.home, Icons.Default.Home, R.string.home), PASSWORD_HEALTH( PasswordHealthRoute, R.string.password_health, Icons.Default.HealthAndSafety, - R.string.password_health + R.string.password_health, + ACTION_OPEN_PASSWORD_HEALTH, ), CONNECTIVITY( RouteDestination.Connectivity, @@ -39,6 +42,12 @@ enum class AppDestinations( Icons.Default.Settings, R.string.settings ), + ; + + companion object { + fun fromIntentAction(action: String?): AppDestinations? = + action?.let { entries.firstOrNull { it.intentAction == action } } + } } val TopLevelRoutes: Set = AppDestinations.entries.mapTo(LinkedHashSet()) { it.route } diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt index 2cc51107c..2cb69f427 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt @@ -2,6 +2,7 @@ package de.davis.keygo.app.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.feature.password_health.domain.PasswordHealthAttention @@ -32,9 +33,21 @@ internal class AppViewModel( val needsAttentionCount: StateFlow = passwordHealthAttention.needsAttention + private val _requestedTopLevelRoute = MutableStateFlow(null) + val requestedTopLevelRoute = _requestedTopLevelRoute.asStateFlow() + init { viewModelScope.launch { _isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() } } } + + fun onIntentAction(action: String?) { + AppDestinations.fromIntentAction(action) + ?.let { dest -> _requestedTopLevelRoute.update { dest.route } } + } + + fun onTopLevelRouteRequestHandled() { + _requestedTopLevelRoute.value = null + } } 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 19a75d5f6..4850e9cd8 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 @@ -52,6 +52,9 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) + // A recreated Activity already restored the tab the request selected. + if (savedInstanceState == null) viewModel.onIntentAction(intent.action) + splashScreen.setKeepOnScreenCondition { viewModel.isReturningUser.value == null } @@ -62,6 +65,7 @@ class MainActivity : FragmentActivity() { val hasAccess = viewModel.isReturningUser.collectAsState().value ?: return@setContent val isSessionActive by viewModel.isSessionActive.collectAsState() val needsAttentionCount by viewModel.needsAttentionCount.collectAsState() + val requestedTopLevelRoute by viewModel.requestedTopLevelRoute.collectAsState() KeyGoTheme { val snackbarManager = koinInject() @@ -73,12 +77,19 @@ class MainActivity : FragmentActivity() { launchRoute = launchRoute(hasAccess), isSessionActive = isSessionActive, needsAttentionCount = needsAttentionCount, + requestedTopLevelRoute = requestedTopLevelRoute, + onTopLevelRouteRequestHandled = viewModel::onTopLevelRouteRequestHandled, ) } } } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + viewModel.onIntentAction(intent.action) + } + private fun launchRoute(hasAccess: Boolean): NavKey = intent.totpImportRedirect() ?: if (hasAccess) AuthRoute() else OnboardingRoute() } @@ -96,6 +107,8 @@ private fun App( launchRoute: NavKey, isSessionActive: Boolean, needsAttentionCount: Int, + requestedTopLevelRoute: NavKey?, + onTopLevelRouteRequestHandled: () -> Unit, ) { val navigationState = rememberAppNavigationState( launchRoute = launchRoute, @@ -106,6 +119,12 @@ private fun App( LockAppWhenSessionEnds(isSessionActive, navigator) + LaunchedEffect(requestedTopLevelRoute) { + if (requestedTopLevelRoute == null) return@LaunchedEffect + navigator.selectBehindOverlay(requestedTopLevelRoute) + onTopLevelRouteRequestHandled() + } + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() val directive = remember(windowAdaptiveInfo) { calculatePaneScaffoldDirective(windowAdaptiveInfo) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt index 67d1210d8..671d4e70d 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt @@ -47,6 +47,15 @@ class AppNavigator(val state: AppNavigationState) { else state.currentStack.add(route) } + /** + * Selects the top level [route] for a request from outside the app, such as a notification. + * Not gated: it only changes what sits under the overlay, so a gate stays up and the unlock + * reveals [route]. + */ + fun selectBehindOverlay(route: NavKey) { + if (route in state.backStacks) state.topLevelRoute = route + } + /** * Switches to the top level [route], keeping whatever history it had. Picking the destination * already showing is what clears it, popping back to its base. Nothing sits underneath a base, diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt new file mode 100644 index 000000000..f8f1decb9 --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/AppDestinationsTest.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.app.presentation + +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AppDestinationsTest { + + @Test + fun `the password health action opens the password health tab`() { + assertEquals( + AppDestinations.PASSWORD_HEALTH, + AppDestinations.fromIntentAction(ACTION_OPEN_PASSWORD_HEALTH), + ) + } + + @Test + fun `the launcher action opens no tab`() { + assertNull(AppDestinations.fromIntentAction("android.intent.action.MAIN")) + } + + @Test + fun `no action opens no tab, even though most tabs have none`() { + assertNull(AppDestinations.fromIntentAction(null)) + } +} diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt index 5d5d01fd5..0e5c00104 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -416,6 +416,40 @@ class AppNavigatorTest { ) } + @Test + fun `a tab selected from outside the app waits behind the gate until the unlock`() { + val navigator = navigator() + + navigator.selectBehindOverlay(PasswordHealthRoute) + + assertEquals(listOf(AuthRoute()), navigator.shown) + + navigator.unlock() + + assertEquals(listOf(PasswordHealthRoute), navigator.shown) + } + + @Test + fun `a tab selected from outside the app keeps the history that tab had`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + navigator.navigate(RouteDestination.Home) + + navigator.selectBehindOverlay(SettingsRoute) + + assertEquals(listOf(SettingsRoute, ChangePasswordRoute), navigator.shown) + } + + @Test + fun `a route that is not a tab is not selected from outside the app`() { + val navigator = unlocked() + + navigator.selectBehindOverlay(ChangePasswordRoute) + + assertEquals(RouteDestination.Home, navigator.state.topLevelRoute) + } + @Test fun `the chrome still standing behind the gate cannot navigate anywhere`() { // The navigation bar animates out rather than disappearing, so it stays clickable for a diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt index fdd6cf48a..e4d0169d1 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/NotifierImpl.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Notification import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.annotation.StringRes @@ -14,6 +15,7 @@ import androidx.core.content.ContextCompat import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.Notifier import de.davis.keygo.feature.password_health.domain.model.KeyGoNotification +import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_HEALTH import org.koin.core.annotation.Single import de.davis.keygo.core.ui.R as CoreUiR @@ -71,7 +73,7 @@ internal class NotifierImpl( .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) .setOnlyAlertOnce(true) .setAutoCancel(true) - .setContentIntent(openAppIntent()) + .setContentIntent(openPasswordHealthIntent()) .build() } @@ -80,10 +82,14 @@ internal class NotifierImpl( .setSmallIcon(CoreUiR.drawable.ic_app) .setContentTitle(context.getString(R.string.password_health_title)) - // TODO: send user to correct screen - private fun openAppIntent(): PendingIntent? { - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) - ?: return null + private fun openPasswordHealthIntent(): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + action = ACTION_OPEN_PASSWORD_HEALTH + + // Without these a running task gets a second MainActivity instead of onNewIntent. + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } ?: return null + return PendingIntent.getActivity( context, 0, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt index 29dc5eac2..882e86135 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthEntries.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.Serializable @Serializable object PasswordHealthRoute : NavKey +const val ACTION_OPEN_PASSWORD_HEALTH = "de.davis.keygo.action.OPEN_PASSWORD_HEALTH" + fun EntryProviderScope.passwordHealthEntries( metadata: Map = emptyMap(), openItemId: ItemId?, From 04d4c8f1c21a13b29c75f290c1f68ca3693be843 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 27 Sep 2026 14:52:20 +0200 Subject: [PATCH 42/54] refactor: resolve todo --- .../domain/model/StoredHealthReport.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt index 03ddbf07d..6586b60ca 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/StoredHealthReport.kt @@ -26,7 +26,7 @@ data class StoredHealthReport( breachCheckEnabled: Boolean, ): Boolean = this.breachCheckEnabled == breachCheckEnabled && items.none { it.unreadable } // if any item was unreadable, we want a new report - && !hasRetryableGap + && !hasGapNeedingFullScan && items.associate { it.id to it.fingerprint } == fingerprints fun breachResultsAt( @@ -37,10 +37,13 @@ data class StoredHealthReport( .mapNotNull { item -> item.breach?.takeIf { it.isCurrentAt(now) }?.let { item.id to it } } .toMap() - // TODO: maybe make this even readable, as this kinda suggests that the scanner actually does the re-checking - // A breach gap leaves its items without a result, so they are retried through breachResultsAt. - private val hasRetryableGap: Boolean - get() = gaps.any { (kind, gap) -> kind != CheckKind.Breach && gap.reason != GapReason.Disabled } + // Breach gaps are left out: their items have no BreachResult, so the scanner looks up just + // those again in refreshBreaches instead of rescanning everything. + private val hasGapNeedingFullScan: Boolean + get() = gaps + .filterKeys { it != CheckKind.Breach } + .values + .any { it.reason != GapReason.Disabled } companion object { const val ALGORITHM_VERSION = 1 From a8ce7dab05fdf74d4b34536ce551799573ea4b58 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 27 Sep 2026 15:35:12 +0200 Subject: [PATCH 43/54] fix: improve performance --- .../presentation/component/ItemHealthRow.kt | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt index 81428e6fc..38ba4949a 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/ItemHealthRow.kt @@ -157,24 +157,30 @@ private fun ItemHealthTitle( inlineBreach: Boolean, modifier: Modifier = Modifier, ) { + // The lookahead pass only pays off when there is a badge to move; skip it for every other row. + if (breach == null) return Text( + text = title, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Box(modifier = modifier) { LookaheadScope { val badge = remember(breach) { - breach?.let { breached -> - movableContentOf { - Text( - text = pluralStringResource( - R.plurals.needs_attention_found_in_breach, - breached.occurrences, - breached.occurrences, - ), - modifier = Modifier.animateBounds(this@LookaheadScope), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + movableContentOf { + Text( + text = pluralStringResource( + R.plurals.needs_attention_found_in_breach, + breach.occurrences, + breach.occurrences, + ), + modifier = Modifier.animateBounds(this@LookaheadScope), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -182,7 +188,7 @@ private fun ItemHealthTitle( modifier = Modifier.animateBounds(this@LookaheadScope), verticalArrangement = Arrangement.spacedBy(TitleLineSpacing), ) { - if (badge != null && !inlineBreach) badge() + if (!inlineBreach) badge() Row( horizontalArrangement = Arrangement.spacedBy(TitleBadgeSpacing), @@ -197,7 +203,7 @@ private fun ItemHealthTitle( overflow = TextOverflow.Ellipsis, ) - if (badge != null && inlineBreach) badge() + if (inlineBreach) badge() } } } From 5d358446b30a000fe5466de5538da3cfa4dcaba9 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 14:13:41 +0200 Subject: [PATCH 44/54] feat: show breach error status in card --- .../presentation/component/BreachCheck.kt | 22 +------------------ .../component/PasswordHealthStatus.kt | 19 ++++++++++++++++ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt index cc41a2108..fa0b1b7e0 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/BreachCheck.kt @@ -7,10 +7,8 @@ import androidx.compose.material3.ListItemShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import de.davis.keygo.core.item.domain.alias.newItemId @@ -53,15 +51,11 @@ private fun BreachCheck( onChange: (Boolean) -> Unit, shapes: ListItemShapes, ) { - val message = state.breachGap?.message() - KeyGoSwitch( checked = state.breachCheckEnabled, onCheckedChange = onChange, supportingContent = { - if (message == null) - Text(text = stringResource(R.string.breach_check_opt_in_description)) - else Text(text = message, color = MaterialTheme.colorScheme.error) + Text(text = stringResource(R.string.breach_check_opt_in_description)) }, verticalAlignment = Alignment.CenterVertically, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), @@ -71,20 +65,6 @@ private fun BreachCheck( } } -@Composable -@ReadOnlyComposable -private fun CheckGap.message(): String? { - val plural = when (reason) { - // An off switch is the whole explanation, so there is nothing to warn about. - GapReason.Disabled -> return null - - GapReason.Unreachable -> R.plurals.breach_check_unreachable - GapReason.Failed -> R.plurals.breach_check_failed - } - - return pluralStringResource(plural, unchecked.size, unchecked.size) -} - @Preview @Composable private fun BreachCheckPreview() { diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt index 11daddfc4..70b8fccbe 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -20,8 +20,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -29,6 +31,8 @@ import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.domain.model.CheckGap +import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase @@ -83,9 +87,24 @@ internal fun PasswordHealthStatus( SupportingLine(text = state.detailLine(), label = "detailLineVisibility") SupportingLine(text = state.coverageNote(), label = "coverageNoteVisibility") + SupportingLine(text = state.breachGap?.message(), label = "breachGapVisibility") } } +@Composable +@ReadOnlyComposable +private fun CheckGap.message(): String? { + val plural = when (reason) { + // The check was disabled by the user, so there is nothing to warn about. + GapReason.Disabled -> return null + + GapReason.Unreachable -> R.plurals.breach_check_unreachable + GapReason.Failed -> R.plurals.breach_check_failed + } + + return pluralStringResource(plural, unchecked.size, unchecked.size) +} + @Composable private fun SupportingLine(text: String?, label: String) { AnimatedVisibility( From e3e524aaaea80bda3fdff86b08c32ff3459a9c1a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 16:17:06 +0200 Subject: [PATCH 45/54] feat: reduce timeout to 3 sec --- .../feature/password_health/di/FeaturePasswordHealthModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt index d3a98ce0b..d705ac455 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/di/FeaturePasswordHealthModule.kt @@ -23,7 +23,7 @@ import kotlin.time.Duration.Companion.seconds @ComponentScan("de.davis.keygo.feature.password_health") object FeaturePasswordHealthModule { - private val LOOKUP_TIMEOUT = 15.seconds + private val LOOKUP_TIMEOUT = 3.seconds private const val MAX_PARALLEL_LOOKUPS = 8 @Single From 95671833056727a61d6feb8fa8baf82cbb5fa793 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 21:23:23 +0200 Subject: [PATCH 46/54] feat: update connectivity checks to use Flow for internet status --- .../repository/ConnectivityRepositoryImpl.kt | 69 ++++++++++++++++--- .../domain/checker/BreachedPasswordCheck.kt | 3 +- .../repository/ConnectivityRepository.kt | 4 +- .../presentation/PasswordHealthViewModel.kt | 41 +++++++++++ .../FakeConnectivityRepository.kt | 14 +++- .../PasswordHealthViewModelTest.kt | 69 ++++++++++++++++++- 6 files changed, 186 insertions(+), 14 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt index d3f511fa0..f7f5f3165 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/ConnectivityRepositoryImpl.kt @@ -2,22 +2,73 @@ package de.davis.keygo.feature.password_health.data.repository import android.content.Context import android.net.ConnectivityManager +import android.net.Network import android.net.NetworkCapabilities +import androidx.core.content.getSystemService +import de.davis.keygo.core.util.di.annotation.AppScopeQualifier import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.shareIn import org.koin.core.annotation.Single @Single -internal class ConnectivityRepositoryImpl(context: Context) : ConnectivityRepository { +internal class ConnectivityRepositoryImpl( + context: Context, + @AppScopeQualifier appScope: CoroutineScope, +) : ConnectivityRepository { - private val connectivity = context.getSystemService(ConnectivityManager::class.java) + private val connectivity = requireNotNull( + context.applicationContext.getSystemService() + ) - override fun hasInternet(): Boolean { - val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) - ?: return false + private val internet = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + private var current: Network? = null - // Validated rules out a network that is joined but has no way out, which is where every - // lookup would otherwise hang until its timeout. - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + current = network + trySend(networkCapabilities.hasInternet()) + } + + // A handover can report the old default as lost after the new one is already up. + override fun onLost(network: Network) { + if (network != current) return + + current = null + trySend(false) + } + } + + trySend(connectivity.isCurrentlyConnected()) + + connectivity.registerDefaultNetworkCallback(callback) + awaitClose { connectivity.unregisterNetworkCallback(callback) } } + .conflate() + .distinctUntilChanged() + // A cached value would outlive the callback that kept it current. + .shareIn(appScope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1) + + override fun observeInternet(): Flow = internet + + private fun ConnectivityManager.isCurrentlyConnected(): Boolean = + activeNetwork + ?.let(::getNetworkCapabilities) + ?.hasInternet() + ?: false + + // Validated rules out a network that is joined but has no way out, which is where every + // lookup would otherwise hang until its timeout. + private fun NetworkCapabilities.hasInternet(): Boolean = + hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt index c7b50a8e5..e25cb3b08 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/checker/BreachedPasswordCheck.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import java.nio.CharBuffer @@ -39,7 +40,7 @@ internal class BreachedPasswordCheck( if (!healthSettingsRepository.getBreachCheckState().breachesEnabled) return CheckOutcome.skipped(GapReason.Disabled, candidates) - if (!connectivityRepository.hasInternet()) + if (!connectivityRepository.observeInternet().first()) return CheckOutcome.skipped(GapReason.Unreachable, candidates) val ranges = candidates.byRange() diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt index e8be1bfdd..8582b5a59 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/repository/ConnectivityRepository.kt @@ -1,5 +1,7 @@ package de.davis.keygo.feature.password_health.domain.repository +import kotlinx.coroutines.flow.Flow + interface ConnectivityRepository { - fun hasInternet(): Boolean + fun observeInternet(): Flow } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 007e0e7b5..1f9cdb7c7 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -9,9 +9,11 @@ import de.davis.keygo.feature.item.core.domain.model.set import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.view.domain.WebsiteHandler import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRepository import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase @@ -21,16 +23,27 @@ import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthE import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest 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 +import kotlin.time.Duration.Companion.seconds @KoinViewModel internal class PasswordHealthViewModel( @@ -39,12 +52,14 @@ internal class PasswordHealthViewModel( private val healthSettingsRepository: HealthSettingsRepository, private val setHealthNotifications: SetHealthNotificationsUseCase, private val websiteHandler: WebsiteHandler, + connectivityRepository: ConnectivityRepository, ) : ViewModel() { private val _eventChannel = Channel(Channel.BUFFERED) val events = _eventChannel.receiveAsFlow() private val _base = MutableStateFlow(PasswordHealthUiState(phase = RunPhase.FirstLoad)) + val uiState = combine( _base, healthSettingsRepository.observeBreachCheckState(), @@ -61,6 +76,20 @@ internal class PasswordHealthViewModel( private var run: Job? = null + init { + @OptIn(ExperimentalCoroutinesApi::class) + _base + .map { it.phase == RunPhase.Idle && it.breachGap?.reason == GapReason.Unreachable } + .distinctUntilChanged() + .mapLatest { stranded -> + if (!stranded) return@mapLatest + + connectivityRepository.observeInternet().awaitReconnect() + runHealthCheck(RunPhase.Refresh) + } + .launchIn(viewModelScope) + } + fun onEvent(event: PasswordHealthUiEvent) { when (event) { PasswordHealthUiEvent.RunHealthCheck -> runHealthCheck(RunPhase.FirstLoad) @@ -164,6 +193,18 @@ internal class PasswordHealthViewModel( } } +private val RECONNECT_SETTLE = 1.seconds + +// Handovers flap the validated state, so a reconnect has to hold before it counts. A lookup can +// time out while the network still reports validated, so the connection has to drop and come back +// first, or the retry would loop. +@OptIn(FlowPreview::class) +private suspend fun Flow.awaitReconnect() { + dropWhile { it } + .debounce(RECONNECT_SETTLE) + .first { it } +} + private fun PasswordHealthUiState.withReport(report: PasswordHealthReport) = copy( phase = RunPhase.Idle, error = null, diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt index 59492cea8..150810f6e 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/FakeConnectivityRepository.kt @@ -1,8 +1,18 @@ package de.davis.keygo.feature.password_health import de.davis.keygo.feature.password_health.domain.repository.ConnectivityRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow -internal class FakeConnectivityRepository(var online: Boolean = true) : ConnectivityRepository { +internal class FakeConnectivityRepository(online: Boolean = true) : ConnectivityRepository { - override fun hasInternet(): Boolean = online + private val internet = MutableStateFlow(online) + + var online: Boolean + get() = internet.value + set(value) { + internet.value = value + } + + override fun observeInternet(): Flow = internet } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index af3023d7e..966efb2c9 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -28,6 +28,7 @@ import de.davis.keygo.feature.password_health.domain.checker.BreachedPasswordChe import de.davis.keygo.feature.password_health.domain.checker.ReusePasswordCheck import de.davis.keygo.feature.password_health.domain.checker.WeakPasswordChecker import de.davis.keygo.feature.password_health.domain.model.FindingSeverity +import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError @@ -54,6 +55,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -64,6 +67,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) class PasswordHealthViewModelTest { @@ -77,6 +81,7 @@ class PasswordHealthViewModelTest { private val websiteHandler = RecordingWebsiteHandler() private val notificationState = FakeHealthNotificationStateRepository() private val scheduler = FakeHealthCheckNotifierScheduler() + private val connectivity = FakeConnectivityRepository() @BeforeTest fun setUp() { @@ -105,7 +110,7 @@ class PasswordHealthViewModelTest { listOf( WeakPasswordChecker(), ReusePasswordCheck(), - BreachedPasswordCheck(breached, breachState, FakeConnectivityRepository()), + BreachedPasswordCheck(breached, breachState, connectivity), ), ), assembler = HealthReportAssembler(), @@ -132,6 +137,7 @@ class PasswordHealthViewModelTest { notifier = FakeNotifier(), ), websiteHandler = websiteHandler, + connectivityRepository = connectivity, ) @Test @@ -263,6 +269,67 @@ class PasswordHealthViewModelTest { assertEquals(0, vm.uiState.value.totalPasswordCount) } + @Test + fun reconnectingRetriesOnlyTheUnreachableBreachLookups() = runTest { + seed(0) + seed(1) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + assertEquals(GapReason.Unreachable, runAndAwait(vm).breachGap?.reason) + + connectivity.online = true + val state = vm.awaitIdle { it.breachGap == null } + + assertEquals(2, breachLookups()) + assertEquals(2, state.totalPasswordCount) + } + + @Test + fun aConnectionThatDropsBeforeSettlingIsNotRetried() = runTest { + seed(0) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + connectivity.online = true + advanceTimeBy(500.milliseconds) + connectivity.online = false + advanceUntilIdle() + + assertEquals(0, breachLookups()) + assertEquals(GapReason.Unreachable, vm.uiState.value.breachGap?.reason) + } + + @Test + fun reconnectingWithoutAnUnreachableGapDoesNotRun() = runTest { + seed(0) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + runAndAwait(vm) + + connectivity.online = true + advanceUntilIdle() + + assertEquals(RunPhase.Idle, vm.uiState.value.phase) + assertEquals(0, breachLookups()) + } + + @Test + fun comingOnlineRightAfterOpeningStillRetries() = runTest { + seed(0) + breachState.setBreachEnabled(true) + connectivity.online = false + val vm = viewModel().also { subscribe(it) } + assertEquals(GapReason.Unreachable, runAndAwait(vm).breachGap?.reason) + + connectivity.online = true + vm.awaitIdle { it.breachGap == null } + + assertEquals(1, breachLookups()) + } + @Test fun clickingAnItemOpensIt() = runTest { val vm = viewModel().also { subscribe(it) } From 48d2f56b875c29d629cddd9a09508e0416ec2cc1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 22:03:19 +0200 Subject: [PATCH 47/54] feat: cluster standalone items by their worst issue and update UI accordingly --- .../component/NeedsAttentionSection.kt | 57 +++++++++++++++++-- .../model/PasswordHealthUiState.kt | 18 ++++++ .../src/main/res/values/strings.xml | 9 +++ .../model/PasswordHealthUiStateTest.kt | 57 +++++++++++++++++-- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt index ebafe70ac..d080d6db9 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/NeedsAttentionSection.kt @@ -18,6 +18,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.ItemId @@ -33,6 +35,8 @@ import de.davis.keygo.feature.password_health.domain.model.RelationType import de.davis.keygo.feature.password_health.presentation.model.FixFlow import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent +import de.davis.keygo.feature.password_health.presentation.model.StandaloneCluster +import de.davis.keygo.feature.password_health.presentation.model.StandaloneIssue internal fun LazyListScope.needsAttentionSection( sections: List, @@ -59,19 +63,29 @@ internal fun LazyListScope.needsAttentionSection( style = MaterialTheme.typography.labelMedium, modifier = Modifier .animateItem() + .semantics { heading() } .padding(top = 12.dp, bottom = 4.dp), ) } section.groups.forEach { group -> - item(key = "group-${group.id}", contentType = ContentType.GroupLabel) { + item(key = "group-${group.id}", contentType = ContentType.ClusterLabel) { GroupLabel(group = group, modifier = Modifier.animateItem()) } segmentedRows(group.orderedMembers, pendingFix, openItemId, onEvent) } - segmentedRows(section.standalone, pendingFix, openItemId, onEvent) + section.standaloneClusters.forEach { cluster -> + item( + key = "standalone-${section.severity}-${cluster.issue}", + contentType = ContentType.ClusterLabel, + ) { + StandaloneLabel(cluster = cluster, modifier = Modifier.animateItem()) + } + + segmentedRows(cluster.items, pendingFix, openItemId, onEvent) + } } } @@ -108,11 +122,35 @@ private fun GroupLabel( RelationType.Similar -> R.plurals.needs_attention_group_similar } + ClusterLabel(text = pluralStringResource(label, count, count), modifier = modifier) +} + +@Composable +private fun StandaloneLabel( + cluster: StandaloneCluster, + modifier: Modifier = Modifier, +) { + val count = cluster.items.size + val label = when (cluster.issue) { + StandaloneIssue.Breached -> R.plurals.needs_attention_standalone_breached + StandaloneIssue.Weak -> R.plurals.needs_attention_standalone_weak + } + + ClusterLabel(text = pluralStringResource(label, count, count), modifier = modifier) +} + +@Composable +private fun ClusterLabel( + text: String, + modifier: Modifier = Modifier, +) { Text( - text = pluralStringResource(label, count, count), + text = text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier.padding(start = 16.dp, top = 8.dp, bottom = 2.dp), + modifier = modifier + .semantics { heading() } + .padding(start = 16.dp, top = 12.dp, bottom = 2.dp), ) } @@ -132,7 +170,7 @@ private val FindingSeverity.labelRes: Int private enum class ContentType { Title, SeverityHeader, - GroupLabel, + ClusterLabel, Row, } @@ -187,7 +225,14 @@ private fun NeedsAttentionSectionPreview() { ), ), ), - standalone = emptyList(), + standalone = listOf( + ItemHealth( + itemId = newItemId(), + title = "Bank", + username = "user@example.com", + issues = listOf(ItemIssue.Weak(score = PasswordScore.Weak)), + ), + ), ), HealthSection( severity = FindingSeverity.Medium, diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 2c87e2c3f..568ab0221 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -79,6 +79,24 @@ internal data class HealthSection( val severity: FindingSeverity, val groups: List, val standalone: List +) { + val standaloneClusters: List = standalone + .groupBy { if (it.breach != null) StandaloneIssue.Breached else StandaloneIssue.Weak } + .let { byIssue -> + StandaloneIssue.entries.mapNotNull { issue -> + byIssue[issue]?.let { StandaloneCluster(issue, it) } + } + } +} + +internal enum class StandaloneIssue { + Breached, + Weak, +} + +internal data class StandaloneCluster( + val issue: StandaloneIssue, + val items: List, ) internal fun List.withoutFixed(fixed: Set): List { diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index e3ed1e489..522e72547 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -47,6 +47,15 @@ Similar passwords across %1$d logins + + Breached password on %1$d login + Breached passwords on %1$d logins + + + Weak password on %1$d login + Weak passwords on %1$d logins + + Found in %1$d breach Found in %1$d breaches diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt index f5b36fb96..7d0359c42 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt @@ -49,14 +49,22 @@ class PasswordHealthUiStateTest { @Test fun checkedPasswordsWithoutFindingsAreAllGood() { - assertEquals(PasswordHealthStatus.ALL_GOOD, PasswordHealthUiState(totalPasswordCount = 3).status) + assertEquals( + PasswordHealthStatus.ALL_GOOD, + PasswordHealthUiState(totalPasswordCount = 3).status + ) } @Test fun anyFindingNeedsAttention() { val state = PasswordHealthUiState( totalPasswordCount = 3, - reportedSections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))), + reportedSections = listOf( + section( + FindingSeverity.Medium, + standalone = listOf(item(0, weak)) + ) + ), ) assertEquals(PasswordHealthStatus.NEEDS_ATTENTION, state.status) @@ -66,7 +74,12 @@ class PasswordHealthUiStateTest { fun fixingEveryFindingIsAllGood() { val state = PasswordHealthUiState( totalPasswordCount = 3, - reportedSections = listOf(section(FindingSeverity.Medium, standalone = listOf(item(0, weak)))), + reportedSections = listOf( + section( + FindingSeverity.Medium, + standalone = listOf(item(0, weak)) + ) + ), optimisticallyFixed = setOf(id(0)), ) @@ -156,7 +169,10 @@ class PasswordHealthUiStateTest { @Test fun aGroupLeftWithOneCleanMemberDisappears() { val sections = listOf( - section(FindingSeverity.High, groups = listOf(group(RelationType.Reused, item(0), item(1)))), + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))) + ), ) assertTrue(sections.withoutFixed(setOf(id(0))).isEmpty()) @@ -178,6 +194,32 @@ class PasswordHealthUiStateTest { assertEquals(listOf(id(1), id(2)), remaining.standalone.map { it.itemId }) } + @Test + fun standaloneItemsAreClusteredByTheirWorstIssueBreachedFirst() { + val section = section( + FindingSeverity.Critical, + standalone = listOf(item(0, weak), item(1, breached, weak), item(2, breached)), + ) + + assertEquals( + listOf( + StandaloneIssue.Breached to listOf(id(1), id(2)), + StandaloneIssue.Weak to listOf(id(0)), + ), + section.standaloneClusters.map { cluster -> cluster.issue to cluster.items.map { it.itemId } }, + ) + } + + @Test + fun noStandaloneItemsMeansNoClusters() { + val section = section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))) + ) + + assertTrue(section.standaloneClusters.isEmpty()) + } + @Test fun summaryCountsEveryFlaggedItemOnce() { val sections = listOf( @@ -244,7 +286,12 @@ class PasswordHealthUiStateTest { private fun group(type: RelationType, vararg members: ItemHealth) = RelatedGroup( id = members.first().itemId, members = members.toSet(), - relations = setOf(HealthFinding.Relation(members.mapTo(mutableSetOf()) { it.itemId }, type)), + relations = setOf( + HealthFinding.Relation( + members.mapTo(mutableSetOf()) { it.itemId }, + type + ) + ), ) private fun section( From 377c7e6d9e4f7a41b24dda312dbce1a6caa71c2f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 22:06:04 +0200 Subject: [PATCH 48/54] fix: clarify that the breach check failed in gap messages --- feature/password-health/src/main/res/values/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index 522e72547..ab513c94e 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -64,12 +64,12 @@ Check for breaches Only the first 5 characters of each password\'s hash ever leave your device - %1$d password went unchecked: couldn\'t reach the breach service - %1$d passwords went unchecked: couldn\'t reach the breach service + Breach check failed for %1$d password: no connection to the breach service + Breach check failed for %1$d passwords: no connection to the breach service - %1$d password went unchecked: the breach service didn\'t answer - %1$d passwords went unchecked: the breach service didn\'t answer + Breach check failed for %1$d password: the breach service didn\'t respond + Breach check failed for %1$d passwords: the breach service didn\'t respond Generated password From 671ca6625da74a770901f4b1005907b08932b7f7 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 16:38:35 +0200 Subject: [PATCH 49/54] refactor: introduce natural order comparator --- .../usecase/ObserveAllTagsSortedUseCase.kt | 5 +- .../ObserveAllTagsSortedUseCaseTest.kt | 6 +- .../comparator/NaturalOrderComparator.kt | 59 +++++ .../core/util/domain/usecase/SortUseCase.kt | 64 ----- .../comparator/NaturalOrderComparatorTest.kt | 231 ++++++++++++++++++ .../util/domain/usecase/SortUseCaseTest.kt | 59 ----- .../import/ImportWizardViewModelTest.kt | 41 ++-- .../presentation/login/LoginViewModelTest.kt | 3 +- .../creditcard/ViewCreditCardViewModel.kt | 5 +- .../item/view/login/ViewLoginViewModel.kt | 8 +- .../creditcard/ViewCreditCardViewModelTest.kt | 5 +- .../domain/usecase/FilterUseCase.kt | 22 +- .../usecase/RankSearchResultsUseCase.kt | 18 +- .../domain/usecase/FilterUseCaseTest.kt | 8 +- .../usecase/RankSearchResultsUseCaseTest.kt | 15 +- .../presentation/ItemListViewModelTest.kt | 9 +- .../ObserveVaultsAndSelectionUseCase.kt | 5 +- .../ObserveVaultsAndSelectionUseCaseTest.kt | 2 - 18 files changed, 364 insertions(+), 201 deletions(-) create mode 100644 core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt delete mode 100644 core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt create mode 100644 core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt delete mode 100644 core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt index ae2f88b88..593f53075 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt @@ -2,7 +2,7 @@ package de.davis.keygo.core.item.domain.usecase import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.repository.ItemRepository -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @@ -10,8 +10,7 @@ import org.koin.core.annotation.Single @Single class ObserveAllTagsSortedUseCase( private val itemRepository: ItemRepository, - private val sortUseCase: SortUseCase, ) { operator fun invoke(): Flow> = - itemRepository.observeAllTags().map { tags -> sortUseCase(tags) { it.display } } + itemRepository.observeAllTags().map { tags -> tags.sortedNaturallyBy { it.display } } } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt index 996fd9b7f..a37065fe7 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt @@ -7,7 +7,6 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp -import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -17,10 +16,7 @@ class ObserveAllTagsSortedUseCaseTest { private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) - private val useCase = ObserveAllTagsSortedUseCase( - itemRepository = itemRepository, - sortUseCase = SortUseCase(), - ) + private val useCase = ObserveAllTagsSortedUseCase(itemRepository = itemRepository) private fun tag(value: String): Tag = Tag.of(value)!! diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt new file mode 100644 index 000000000..72af9143b --- /dev/null +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt @@ -0,0 +1,59 @@ +package de.davis.keygo.core.util.domain.comparator + +import java.text.Collator + +object NaturalOrderComparator : Comparator { + + private val collator = ThreadLocal.withInitial { + Collator.getInstance().apply { strength = Collator.PRIMARY } + } + + private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") + + override fun compare(o1: String?, o2: String?): Int = + compareParts(o1?.split(CHUNK_REGEX).orEmpty(), o2?.split(CHUNK_REGEX).orEmpty()) + + private fun compareParts(parts1: List, parts2: List): Int { + val len = minOf(parts1.size, parts2.size) + + for (i in 0 until len) { + val p1 = parts1[i] + val p2 = parts2[i] + + // Guard against empty chunks produced by splitting blank/empty names + if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length + + val cmp = when { + // split regex guarantees entire chunk is digits if first char is + p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) + else -> collator.get()!!.compare(p1, p2) // locale-aware: ä, ö, ü, &, / etc. + } + + if (cmp != 0) return cmp + } + + return parts1.size.compareTo(parts2.size) + } + + private fun compareNumeric(a: String, b: String): Int { + val x = a.trimStart('0') + val y = b.trimStart('0') + // Without leading zeros, different lengths mean different magnitudes, so no parsing + if (x.length != y.length) return x.length - y.length + // Same length: lexicographic order == numeric order for digit-only strings + return x.compareTo(y) + } +} + +fun Iterable.sortedNaturally(): List = sortedWith(NaturalOrderComparator) + +fun Iterable.sortedNaturallyDescending(): List = + sortedWith(NaturalOrderComparator.reversed()) + +inline fun Iterable.sortedNaturallyBy(crossinline selector: (T) -> String): List = + sortedWith(compareBy(NaturalOrderComparator, selector)) + +inline fun Iterable.sortedNaturallyByDescending(crossinline selector: (T) -> String): List = + sortedWith(compareByDescending(NaturalOrderComparator, selector)) + +fun MutableList.sortNaturally() = sortWith(NaturalOrderComparator) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt deleted file mode 100644 index 0da5a5a34..000000000 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt +++ /dev/null @@ -1,64 +0,0 @@ -package de.davis.keygo.core.util.domain.usecase - -import org.koin.core.annotation.Single -import java.text.Collator - -@Single -class SortUseCase { - - private val collator = ThreadLocal.withInitial { - Collator.getInstance().apply { - strength = Collator.PRIMARY // case- and accent-insensitive grouping - } - } - - operator fun invoke( - items: Iterable, - ascending: Boolean = true, - selector: (T) -> String, - ): List { - val prepared = items.map { item -> - item to selector(item).split(CHUNK_REGEX) - } - - val comparator = Comparator>> { a, b -> - val cmp = compareParts(a.second, b.second) - if (ascending) cmp else -cmp - } - - return prepared.sortedWith(comparator).map { it.first } - } - - private fun compareParts(parts1: List, parts2: List): Int { - val len = minOf(parts1.size, parts2.size) - - for (i in 0 until len) { - val p1 = parts1[i] - val p2 = parts2[i] - - // Guard against empty chunks produced by splitting blank/empty names - if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length - - val cmp = when { - // split regex guarantees entire chunk is digits if first char is - p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) - else -> collator.get()!!.compare(p1, p2) // locale-aware: ä, ö, ü, &, / etc. - } - - if (cmp != 0) return cmp - } - - return parts1.size.compareTo(parts2.size) - } - - private fun compareNumeric(a: String, b: String): Int { - // Fast path: different lengths mean different magnitudes, so no parsing - if (a.length != b.length) return a.length - b.length - // Same length: lexicographic order == numeric order for digit-only strings - return a.compareTo(b) - } - - companion object { - private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") - } -} diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt new file mode 100644 index 000000000..1443915b9 --- /dev/null +++ b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt @@ -0,0 +1,231 @@ +package de.davis.keygo.core.util.domain.comparator + +import kotlin.math.sign +import kotlin.test.Test +import kotlin.test.assertEquals + +class NaturalOrderComparatorTest { + + @Test + fun `sorts ascending case-insensitively`() { + val result = listOf("banana", "Apple", "cherry").sortedNaturally() + assertEquals(listOf("Apple", "banana", "cherry"), result) + } + + @Test + fun `empty input returns empty list`() { + assertEquals(emptyList(), listOf().sortedNaturally()) + } + + @Test + fun `single element list is returned unchanged`() { + assertEquals(listOf("only"), listOf("only").sortedNaturally()) + } + + @Test + fun `empty strings sort first`() { + val result = listOf("b", "", "a").sortedNaturally() + assertEquals(listOf("", "a", "b"), result) + } + + @Test + fun `shorter prefix sorts before longer string`() { + val result = listOf("item1", "item", "itemA").sortedNaturally() + assertEquals(listOf("item", "item1", "itemA"), result) + } + + @Test + fun `natural numeric ordering puts item2 before item10`() { + val result = listOf("item10", "item2", "item1").sortedNaturally() + assertEquals(listOf("item1", "item2", "item10"), result) + } + + @Test + fun `leading numbers are compared numerically`() { + val result = listOf("10 apples", "2 apples", "1 apple").sortedNaturally() + assertEquals(listOf("1 apple", "2 apples", "10 apples"), result) + } + + @Test + fun `pure numeric strings are compared numerically`() { + val result = listOf("100", "20", "3", "1000").sortedNaturally() + assertEquals(listOf("3", "20", "100", "1000"), result) + } + + @Test + fun `multiple numeric segments are each compared numerically`() { + val result = listOf("v1.10.0", "v1.2.10", "v1.2.0", "v2.0.0").sortedNaturally() + assertEquals(listOf("v1.2.0", "v1.2.10", "v1.10.0", "v2.0.0"), result) + } + + @Test + fun `numbers larger than Long do not overflow`() { + val huge = "file" + "9".repeat(30) + val bigger = "file1" + "0".repeat(30) + val result = listOf(bigger, huge, "file1").sortedNaturally() + assertEquals(listOf("file1", huge, bigger), result) + } + + @Test + fun `leading zeros do not inflate magnitude`() { + val result = listOf("item10", "item007", "item2").sortedNaturally() + assertEquals(listOf("item2", "item007", "item10"), result) + } + + @Test + fun `numbers differing only in leading zeros compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("a7", "a007")) + } + + @Test + fun `leading-zero-only duplicates keep input order`() { + assertEquals(listOf("a007", "a7", "a07"), listOf("a007", "a7", "a07").sortedNaturally()) + assertEquals(listOf("a07", "a007", "a7"), listOf("a07", "a007", "a7").sortedNaturally()) + } + + @Test + fun `all-zero numbers sort before non-zero ones`() { + val result = listOf("x5", "x000", "x1").sortedNaturally() + assertEquals(listOf("x000", "x1", "x5"), result) + } + + @Test + fun `case differences do not break numeric ordering`() { + val result = listOf("file10", "File2", "FILE1").sortedNaturally() + assertEquals(listOf("FILE1", "File2", "file10"), result) + } + + @Test + fun `strings differing only in case compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("Keygo", "KEYGO")) + } + + @Test + fun `case-only duplicates keep input order`() { + assertEquals(listOf("a", "A", "b"), listOf("b", "a", "A").sortedNaturally()) + assertEquals(listOf("A", "a", "b"), listOf("b", "A", "a").sortedNaturally()) + } + + @Test + fun `umlauts sort alongside their base letter`() { + // Assumes a default locale such as de or en (not e.g. sv, where Ä follows Z) + val result = listOf("Zebra", "Äpfel", "Birne").sortedNaturally() + assertEquals(listOf("Äpfel", "Birne", "Zebra"), result) + } + + @Test + fun `accented and unaccented letters compare as equal`() { + assertEquals(0, NaturalOrderComparator.compare("Muller", "Müller")) + } + + @Test + fun `null compares equal to null`() { + assertEquals(0, NaturalOrderComparator.compare(null, null)) + } + + @Test + fun `null sorts before non-null values`() { + assert(NaturalOrderComparator.compare(null, "a") < 0) + assert(NaturalOrderComparator.compare("a", null) > 0) + } + + private val samples = listOf( + "", "a", "A", "b", "item", "item1", "item2", "item10", "Item10", + "10", "2", "v1.2.0", "v1.10.0", "Äpfel", "apfel", "x9y", "x10y", + "x09y", "x009y", "item02", "0", "000", + ) + + @Test + fun `comparison is antisymmetric`() { + for (a in samples) for (b in samples) { + val ab = NaturalOrderComparator.compare(a, b).sign + val ba = NaturalOrderComparator.compare(b, a).sign + assertEquals(-ab, ba, "compare($a, $b) and compare($b, $a) disagree") + } + } + + @Test + fun `comparison is transitive`() { + for (a in samples) for (b in samples) for (c in samples) { + val ab = NaturalOrderComparator.compare(a, b) + val bc = NaturalOrderComparator.compare(b, c) + if (ab <= 0 && bc <= 0) { + assert(NaturalOrderComparator.compare(a, c) <= 0) { + "Transitivity violated for $a <= $b <= $c" + } + } + } + } + + @Test + fun `every string compares equal to itself`() { + samples.forEach { assertEquals(0, NaturalOrderComparator.compare(it, it), "for '$it'") } + } + + @Test + fun `sorting is idempotent`() { + val once = samples.shuffled().sortedNaturally() + assertEquals(once, once.sortedNaturally()) + } + + @Test + fun `descending reverses the natural order`() { + val result = listOf("item2", "item10", "item1").sortedNaturallyDescending() + assertEquals(listOf("item10", "item2", "item1"), result) + } + + @Test + fun `descending is the exact reverse of ascending for distinct keys`() { + val input = listOf("b10", "a2", "c1", "a10", "b2") + assertEquals(input.sortedNaturally().reversed(), input.sortedNaturallyDescending()) + } + + private data class Box(val name: String, val tag: Int) + + @Test + fun `sorts by a non-identity selector`() { + val result = listOf(Box("z", 1), Box("a", 2)).sortedNaturallyBy { it.name } + assertEquals(listOf(Box("a", 2), Box("z", 1)), result) + } + + @Test + fun `selector uses natural numeric ordering`() { + val input = listOf(Box("Key 10", 1), Box("Key 2", 2), Box("Key 1", 3)) + val result = input.sortedNaturallyBy { it.name } + assertEquals(listOf(3, 2, 1), result.map { it.tag }) + } + + @Test + fun `descending selector reverses order`() { + val input = listOf(Box("Key 2", 1), Box("Key 10", 2), Box("Key 1", 3)) + val result = input.sortedNaturallyByDescending { it.name } + assertEquals(listOf(2, 1, 3), result.map { it.tag }) + } + + @Test + fun `equal sort keys preserve input order (stable)`() { + val result = listOf(Box("x", 1), Box("x", 2), Box("x", 3)).sortedNaturallyBy { it.name } + assertEquals(listOf(1, 2, 3), result.map { it.tag }) + } + + @Test + fun `keys equal under collator preserve input order (stable)`() { + val input = listOf(Box("KEY", 1), Box("key", 2), Box("Key", 3)) + val result = input.sortedNaturallyBy { it.name } + assertEquals(listOf(1, 2, 3), result.map { it.tag }) + } + + @Test + fun `sortedNaturally does not mutate the original list`() { + val input = mutableListOf("b", "a") + input.sortedNaturally() + assertEquals(listOf("b", "a"), input) + } + + @Test + fun `sortNaturally sorts a mutable list in place`() { + val list = mutableListOf("item10", "item2", "item1") + list.sortNaturally() + assertEquals(listOf("item1", "item2", "item10"), list) + } +} diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt deleted file mode 100644 index 54ff26efe..000000000 --- a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package de.davis.keygo.core.util.domain.usecase - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SortUseCaseTest { - - private val sort = SortUseCase() - - private operator fun SortUseCase.invoke( - items: Iterable, - ascending: Boolean = true, - ) = invoke(items, ascending) { it } - - @Test - fun `sorts ascending case-insensitively by selector`() { - val result = sort(listOf("banana", "Apple", "cherry")) - assertEquals(listOf("Apple", "banana", "cherry"), result) - } - - @Test - fun `natural numeric ordering puts item2 before item10`() { - val result = sort(listOf("item10", "item2", "item1")) - assertEquals(listOf("item1", "item2", "item10"), result) - } - - @Test - fun `descending reverses the natural order`() { - val result = sort(listOf("item2", "item10", "item1"), ascending = false) - assertEquals(listOf("item10", "item2", "item1"), result) - } - - @Test - fun `blank and empty selector values sort first`() { - val result = sort(listOf("b", "", "a")) - assertEquals(listOf("", "a", "b"), result) - } - - @Test - fun `sorts by a non-identity selector`() { - data class Box(val name: String, val n: Int) - - val result = sort(listOf(Box("z", 1), Box("a", 2))) { it.name } - assertEquals(listOf(Box("a", 2), Box("z", 1)), result) - } - - @Test - fun `equal sort keys preserve input order (stable)`() { - data class Box(val name: String, val tag: Int) - - val result = sort(listOf(Box("x", 1), Box("x", 2), Box("x", 3))) { it.name } - assertEquals(listOf(1, 2, 3), result.map { it.tag }) - } - - @Test - fun `empty input returns empty list`() { - assertEquals(emptyList(), sort(emptyList())) - } -} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt index f05369a5c..5ed70eb57 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -6,7 +6,6 @@ import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.domain.Session -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.backup.FakeBackupFileStore import de.davis.keygo.feature.backup.RestorerTestEnv import de.davis.keygo.feature.backup.backupVault @@ -104,7 +103,7 @@ class ImportWizardViewModelTest { resolver, ImportBackupUseCase(fileStore, json, csv, env.restorer, session), AnalyzeCsvUseCase(fileStore, csv), - ObserveVaultsAndSelectionUseCase(env.vaultRepo, contextRepo, SortUseCase()), + ObserveVaultsAndSelectionUseCase(env.vaultRepo, contextRepo), ).also { it.state.launchIn(backgroundScope) } private fun ImportWizardViewModel.selectJson() { @@ -746,29 +745,33 @@ class ImportWizardViewModelTest { viewModel.seedFile(BackupDestinationUri("content://doc/keygo.json")) val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } - assertEquals(1, assertIs(finalState.progress).summary.imported) + assertEquals( + 1, + assertIs(finalState.progress).summary.imported + ) assertEquals(emptyList(), finalState.columns) } @Test - fun `seedFile resolves the destination without a concurrent state write re-running it`() = runTest { - val gate = CompletableDeferred() - val resolver = FakeBackupDestinationResolver(result = textDestination(), gate = gate) - val viewModel = viewModel(resolver) + fun `seedFile resolves the destination without a concurrent state write re-running it`() = + runTest { + val gate = CompletableDeferred() + val resolver = FakeBackupDestinationResolver(result = textDestination(), gate = gate) + val viewModel = viewModel(resolver) - viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) - // seedFile's coroutine has called resolve() and is now parked on the gate, mid-CAS-lambda. - runCurrent() + viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) + // seedFile's coroutine has called resolve() and is now parked on the gate, mid-CAS-lambda. + runCurrent() - // A state write that lands while that lambda is still suspended: this is the same window - // the passphraseState.clearText() collector writes into in the real flow. If resolve() were - // still called from inside _state.update, the CAS retry this forces would call it again. - env.vaultRepo.seed(testVault(name = "Personal")) - runCurrent() + // A state write that lands while that lambda is still suspended: this is the same window + // the passphraseState.clearText() collector writes into in the real flow. If resolve() were + // still called from inside _state.update, the CAS retry this forces would call it again. + env.vaultRepo.seed(testVault(name = "Personal")) + runCurrent() - gate.complete(Unit) - viewModel.state.first { it.backupDestination != null } + gate.complete(Unit) + viewModel.state.first { it.backupDestination != null } - assertEquals(listOf(BackupDestinationUri("content://doc/notes.txt")), resolver.calls) - } + assertEquals(listOf(BackupDestinationUri("content://doc/notes.txt")), resolver.calls) + } } diff --git a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt index b51d9775e..81fc37c9e 100644 --- a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt +++ b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt @@ -22,7 +22,6 @@ import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.domain.snackbar.SnackbarManager -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.core.domain.usecase.ValidateTotpInputUseCase import de.davis.keygo.feature.item.core.presentation.login.model.FieldType @@ -303,7 +302,7 @@ class LoginViewModelTest { registrableDomainResolver = domainResolver, vaultContextRepository = vaultContextRepository, itemRepository = itemRepository, - observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, SortUseCase()), + observeAllTags = ObserveAllTagsSortedUseCase(itemRepository), vaultRepository = vaultRepository, validateTotpInput = ValidateTotpInputUseCase(totpService) ) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index 4ac694c16..666c63766 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -11,7 +11,7 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -61,7 +61,6 @@ internal class ViewCreditCardViewModel( private val updateCreditCard: CreateNewOrUpdateCreditCardUseCase, private val observeCreditCardWithCryptoScope: ItemWithCryptoScopeUseCase, private val observeAllTags: ObserveAllTagsSortedUseCase, - private val sort: SortUseCase, private val cardFormatter: CardFormatter, ) : ViewModel() { @@ -101,7 +100,7 @@ internal class ViewCreditCardViewModel( cardNumber = number, cvv = cvv, expirationDate = card.expirationDate?.format(EXPIRATION_FORMATTER).orEmpty(), - tags = sort(card.tags) { it.display }.toSet(), + tags = card.tags.sortedNaturallyBy { it.display }.toSet(), note = card.note.orEmpty(), pinned = card.pinned, ) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index cbb74dc06..6d5b09757 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -12,8 +12,8 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isSuccess @@ -68,7 +68,6 @@ internal class ViewLoginViewModel( private val updateLogin: CreateNewOrUpdateLoginUseCase, private val validateTotpInput: ValidateTotpInputUseCase, private val isValidUrl: IsValidUrlUseCase, - private val sort: SortUseCase, private val websiteHandler: WebsiteHandler, private val totpGenerator: TotpGenerator, private val registrableDomainResolver: RegistrableDomainResolver, @@ -114,7 +113,7 @@ internal class ViewLoginViewModel( passwordStrengthScore = login.passwordCredential?.score, username = login.username.orEmpty(), domains = login.domainInfos, - tags = sort(login.tags) { it.display }.toSet(), + tags = login.tags.sortedNaturallyBy { it.display }.toSet(), note = login.note.orEmpty(), totpState = TotpState.NoTotp, pinned = login.pinned, @@ -337,7 +336,8 @@ internal class ViewLoginViewModel( dialog.copy( error = when { failure.contains(ItemUpsertError.BlankName) || - failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + else -> InputFieldError.System }, ) diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 41d59d7f8..9d6f59b86 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -16,7 +16,6 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.rust.FakeCardFormatter @@ -121,8 +120,7 @@ class ViewCreditCardViewModelTest { } private fun makeViewModel(): ViewCreditCardViewModel { - val sort = SortUseCase() - val observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sort) + val observeAllTags = ObserveAllTagsSortedUseCase(itemRepository) val cryptoScopeUseCase = ItemWithCryptoScopeUseCase(vaultRepository, cryptoProvider) val upsertVaultItem = UpsertVaultItemUseCase(FakeLoginRepository(), creditCardRepository) val updateCreditCard = CreateNewOrUpdateCreditCardUseCase( @@ -139,7 +137,6 @@ class ViewCreditCardViewModelTest { updateCreditCard = updateCreditCard, observeCreditCardWithCryptoScope = cryptoScopeUseCase, observeAllTags = observeAllTags, - sort = sort, cardFormatter = cardFormatter, ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index 0e244dbdd..204008764 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -4,15 +4,13 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single @Single -class FilterUseCase( - private val sortUseCase: SortUseCase, -) { +class FilterUseCase { operator fun invoke( filterState: FilterState, @@ -27,11 +25,18 @@ class FilterUseCase( matchesTags(tagMatchingIds, item) } - val (pinned, unpinned) = filtered.partition { it.pinned } - return sort(filterState.sortDirection, pinned) + - sort(filterState.sortDirection, unpinned) + return filtered.sortedWith( + compareByDescending { it.pinned } + .thenBy(filterState.sortDirection.nameOrder) { it.name }, + ) } + private val SortDirection.nameOrder: Comparator + get() = when (this) { + SortDirection.Ascending -> NaturalOrderComparator + SortDirection.Descending -> NaturalOrderComparator.reversed() + } + private fun matchesPinnedState(filterState: FilterState, item: LiteItem): Boolean = !filterState.onlyPinned || item.pinned @@ -52,7 +57,4 @@ class FilterUseCase( val score = passwordScores[item.id] ?: return false return score in filterState.selectedScores } - - private fun sort(direction: SortDirection, items: List): List = - sortUseCase(items, ascending = direction == SortDirection.Ascending) { it.name } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt index 60ff7cba1..efddc6a77 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt @@ -1,24 +1,18 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import org.koin.core.annotation.Single @Single -class RankSearchResultsUseCase( - private val sortUseCase: SortUseCase, -) { +class RankSearchResultsUseCase { operator fun invoke( query: String, results: List, - ): List { - val alphabetical = sortUseCase(results) { it.name } - if (query.isBlank()) return alphabetical - - // sortedBy is stable, so the alphabetical order survives within each rank. - return alphabetical.sortedBy { rankOf(query, it) } - } + ): List = + if (query.isBlank()) results.sortedWith(ByName) + else results.sortedWith(compareBy { rankOf(query, it) }.then(ByName)) private fun rankOf(query: String, result: LiteItemSearchResult): Int = when { result.name.equals(query, ignoreCase = true) -> EXACT_NAME @@ -36,5 +30,7 @@ class RankSearchResultsUseCase( const val USERNAME = 3 const val TAG = 4 const val NOTE = 5 + + val ByName = compareBy(NaturalOrderComparator) { it.name } } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index 9fc63d384..82666c4b1 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -5,7 +5,6 @@ import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.SortDirection import java.util.UUID @@ -15,7 +14,7 @@ import kotlin.test.assertTrue class FilterUseCaseTest { - private val useCase: FilterUseCase = FilterUseCase(SortUseCase()) + private val useCase: FilterUseCase = FilterUseCase() private val filterStateAsc = FilterState(sortDirection = SortDirection.Ascending) private val filterStateDesc = FilterState(sortDirection = SortDirection.Descending) @@ -58,14 +57,15 @@ class FilterUseCaseTest { fun `sorts items with numeric suffixes alphanumerically desc`() = sorting("AAA 8", "AAA 9", "AAA 10").descProduces("AAA 10", "AAA 9", "AAA 8") + // 02 and 2 compare as equal, so they keep their input order @Test fun `sorts items with leading zeros and multi-digit numbers`() = sorting("file10", "file02", "file2", "file1") - .ascProduces("file1", "file2", "file02", "file10") + .ascProduces("file1", "file02", "file2", "file10") @Test fun `sorts items with leading numbers`() = sorting("10file", "02file", "2file", "1file") - .ascProduces("1file", "2file", "02file", "10file") + .ascProduces("1file", "02file", "2file", "10file") @Test fun `filtering an empty list returns empty list`() { diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt index 851fd608f..0ec0d5ee7 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt @@ -2,14 +2,13 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.util.domain.usecase.SortUseCase import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals class RankSearchResultsUseCaseTest { - private val useCase = RankSearchResultsUseCase(SortUseCase()) + private val useCase = RankSearchResultsUseCase() private fun result( name: String, @@ -113,4 +112,16 @@ class RankSearchResultsUseCaseTest { assertEquals(listOf("alpha", "Bravo", "Charlie"), ordered) } + + @Test + fun `a whitespace-only query falls back to plain alphabetical order`() { + val ordered = rank( + " ", + result("Charlie"), + result("Zulu name", matchedName = true), + result("Bravo"), + ) + + assertEquals(listOf("Bravo", "Charlie", "Zulu name"), ordered) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index 29543725f..b025e5d52 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt @@ -13,7 +13,6 @@ import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.model.Event @@ -50,7 +49,6 @@ class ItemListViewModelTest { private val itemRepository = FakeItemRepository(loginRepository) private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() - private val sortUseCase = SortUseCase() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -62,13 +60,12 @@ class ItemListViewModelTest { enableSelection = enableSelection, restrictedItemType = null, itemRepository = itemRepository, - filterUseCase = FilterUseCase(sortUseCase), - rankSearchResults = RankSearchResultsUseCase(sortUseCase), - observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sortUseCase), + filterUseCase = FilterUseCase(), + rankSearchResults = RankSearchResultsUseCase(), + observeAllTags = ObserveAllTagsSortedUseCase(itemRepository), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, - sortUseCase = sortUseCase, ), loginRepository = loginRepository, ) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt index 2e660578f..b6da8d6be 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt @@ -2,7 +2,7 @@ package de.davis.keygo.feature.vault.domain.usecase import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository -import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy import de.davis.keygo.feature.vault.domain.model.VaultsAndSelection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -12,14 +12,13 @@ import org.koin.core.annotation.Single class ObserveVaultsAndSelectionUseCase( private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, - private val sortUseCase: SortUseCase, ) { operator fun invoke(): Flow = combine( vaultRepository.observeAllVaultMetadata(), vaultContextRepository.observeVaultContext(), ) { vaults, context -> VaultsAndSelection( - vaults = sortUseCase(vaults) { it.name }, + vaults = vaults.sortedNaturallyBy { it.name }, selection = context, ) } diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt index 2e1d21c7f..8fd49cbcb 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt @@ -7,7 +7,6 @@ import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultContext -import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -24,7 +23,6 @@ class ObserveVaultsAndSelectionUseCaseTest { private val useCase = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, - sortUseCase = SortUseCase(), ) @Test From 30290008cfdbc29f4c4be4605e2fd46b58656d86 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 17:12:07 +0200 Subject: [PATCH 50/54] refactor: improve ordering performance --- .../comparator/NaturalOrderComparator.kt | 76 +++++++++++++------ .../comparator/NaturalOrderComparatorTest.kt | 1 + 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt index 72af9143b..d0de5478d 100644 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparator.kt @@ -4,44 +4,74 @@ import java.text.Collator object NaturalOrderComparator : Comparator { + // Locale-aware for text runs: ä, ö, ü, &, / etc. private val collator = ThreadLocal.withInitial { Collator.getInstance().apply { strength = Collator.PRIMARY } } - private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") + // Walks both strings run by run (a run is all digits or no digits) instead of splitting them, + // since this runs O(n log n) times per sort + override fun compare(o1: String?, o2: String?): Int { + if (o1 == null || o2 == null) return compareValues(o1, o2) - override fun compare(o1: String?, o2: String?): Int = - compareParts(o1?.split(CHUNK_REGEX).orEmpty(), o2?.split(CHUNK_REGEX).orEmpty()) - - private fun compareParts(parts1: List, parts2: List): Int { - val len = minOf(parts1.size, parts2.size) - - for (i in 0 until len) { - val p1 = parts1[i] - val p2 = parts2[i] - - // Guard against empty chunks produced by splitting blank/empty names - if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length + var i = 0 + var j = 0 + while (i < o1.length && j < o2.length) { + val digits1 = o1[i].isAsciiDigit() + val digits2 = o2[j].isAsciiDigit() + val end1 = runEnd(o1, i, digits1) + val end2 = runEnd(o2, j, digits2) val cmp = when { - // split regex guarantees entire chunk is digits if first char is - p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) - else -> collator.get()!!.compare(p1, p2) // locale-aware: ä, ö, ü, &, / etc. + digits1 && digits2 -> compareNumeric(o1, i, end1, o2, j, end2) + // Shared runs ("Server " in "Server 2" vs "Server 10") are common and the collator + // plus its substrings is the slow part, so identical runs skip it. Only exact + // equality may: any looser shortcut (ASCII order, ignoreCase) disagrees with the + // collator somewhere and breaks transitivity + end1 - i == end2 - j && o1.regionMatches(i, o2, j, end1 - i) -> 0 + else -> collator.get()!!.compare(o1.substring(i, end1), o2.substring(j, end2)) } if (cmp != 0) return cmp + i = end1 + j = end2 } - return parts1.size.compareTo(parts2.size) + // Whoever has runs left is longer: "item" < "item1" + return (o1.length - i).compareTo(o2.length - j) } - private fun compareNumeric(a: String, b: String): Int { - val x = a.trimStart('0') - val y = b.trimStart('0') + private fun Char.isAsciiDigit() = this in '0'..'9' + + private fun runEnd(s: String, from: Int, digits: Boolean): Int { + var k = from + while (k < s.length && s[k].isAsciiDigit() == digits) k++ + return k + } + + private fun compareNumeric( + a: String, + aStart: Int, + aEnd: Int, + b: String, + bStart: Int, + bEnd: Int + ): Int { + var i = aStart + while (i < aEnd && a[i] == '0') i++ + var j = bStart + while (j < bEnd && b[j] == '0') j++ + // Without leading zeros, different lengths mean different magnitudes, so no parsing - if (x.length != y.length) return x.length - y.length - // Same length: lexicographic order == numeric order for digit-only strings - return x.compareTo(y) + val lengthDiff = (aEnd - i) - (bEnd - j) + if (lengthDiff != 0) return lengthDiff + + // Same length: digit-by-digit order == numeric order + while (i < aEnd) { + val d = a[i++] - b[j++] + if (d != 0) return d + } + return 0 } } diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt index 1443915b9..970636e1d 100644 --- a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt +++ b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/comparator/NaturalOrderComparatorTest.kt @@ -133,6 +133,7 @@ class NaturalOrderComparatorTest { "", "a", "A", "b", "item", "item1", "item2", "item10", "Item10", "10", "2", "v1.2.0", "v1.10.0", "Äpfel", "apfel", "x9y", "x10y", "x09y", "x009y", "item02", "0", "000", + "x٣", "٣", "x5", "٣x", ) @Test From 6a5950659647bd7f51ad85df346c641f84810648 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 18:06:35 +0200 Subject: [PATCH 51/54] feat: sort health findings --- .../domain/model/PasswordHealthReport.kt | 6 +- .../presentation/PasswordHealthViewModel.kt | 14 +-- .../model/PasswordHealthUiState.kt | 23 ++++- .../model/PasswordHealthUiStateTest.kt | 89 ++++++++++++++++++- 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt index 7958aff43..0c4c726f2 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/domain/model/PasswordHealthReport.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.password_health.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator data class ItemHealth( val itemId: ItemId, @@ -38,7 +39,10 @@ data class RelatedGroup( }.thenBy { (type, _) -> type.severity }, ) .key - val orderedMembers: List = members.sortedByDescending { it.maxSeverity } + val orderedMembers: List = members.sortedWith( + compareByDescending { it.maxSeverity } + .thenBy(NaturalOrderComparator) { it.title }, + ) } data class PasswordHealthReport( diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt index 1f9cdb7c7..e2a6e1112 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModel.kt @@ -8,7 +8,6 @@ import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.set import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.view.domain.WebsiteHandler -import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.PasswordFixError import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport @@ -18,11 +17,11 @@ import de.davis.keygo.feature.password_health.domain.repository.HealthSettingsRe import de.davis.keygo.feature.password_health.domain.usecase.PasswordHealthReportUseCase import de.davis.keygo.feature.password_health.domain.usecase.SetHealthNotificationsUseCase import de.davis.keygo.feature.password_health.presentation.model.FixFlow -import de.davis.keygo.feature.password_health.presentation.model.HealthSection import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiEvent import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase +import de.davis.keygo.feature.password_health.presentation.model.toSections import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -224,14 +223,3 @@ private fun PasswordHealthUiState.withError(error: PasswordHealthReportError) = unreadable = emptySet(), optimisticallyFixed = emptySet(), ) - -private fun PasswordHealthReport.toSections(): List { - val groupsBySeverity = groups.groupBy { it.maxSeverity } - val standaloneBySeverity = standalone.groupBy { requireNotNull(it.maxSeverity) } - - return FindingSeverity.entries.sortedDescending().mapNotNull { severity -> - val g = groupsBySeverity[severity].orEmpty().sortedByDescending { it.members.size } - val s = standaloneBySeverity[severity].orEmpty().sortedByDescending { it.issues.size } - if (g.isEmpty() && s.isEmpty()) null else HealthSection(severity, g, s) - } -} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 568ab0221..569749987 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.CheckKind import de.davis.keygo.feature.password_health.domain.model.FindingSeverity import de.davis.keygo.feature.password_health.domain.model.ItemHealth import de.davis.keygo.feature.password_health.domain.model.ItemIssue +import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReport import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError import de.davis.keygo.feature.password_health.domain.model.RelatedGroup import de.davis.keygo.feature.password_health.domain.model.RelationType @@ -99,6 +101,23 @@ internal data class StandaloneCluster( val items: List, ) +internal fun PasswordHealthReport.toSections(): List { + val groupsBySeverity = groups.groupBy { it.maxSeverity } + val standaloneBySeverity = standalone.groupBy { requireNotNull(it.maxSeverity) } + + return FindingSeverity.entries.sortedDescending().mapNotNull { severity -> + val g = groupsBySeverity[severity].orEmpty().sortedWith(GroupOrder) + val s = standaloneBySeverity[severity].orEmpty().sortedWith(StandaloneOrder) + if (g.isEmpty() && s.isEmpty()) null else HealthSection(severity, g, s) + } +} + +private val GroupOrder = compareByDescending { it.members.size } + .thenBy(NaturalOrderComparator) { it.orderedMembers.first().title } + +private val StandaloneOrder = compareByDescending { it.maxSeverity } + .thenBy(NaturalOrderComparator) { it.title } + internal fun List.withoutFixed(fixed: Set): List { if (fixed.isEmpty()) return this @@ -113,10 +132,10 @@ internal fun List.withoutFixed(fixed: Set): List = emptyList(), + standalone: List = emptyList(), + ) = PasswordHealthReport(groups, standalone, totalPasswordsScanned = 10) + private fun section( severity: FindingSeverity, groups: List = emptyList(), From 10ba391569c25f42da09de9cf606cb5cb3c19e4a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 21:14:54 +0200 Subject: [PATCH 52/54] feat: add severity breakdown bar for password health status Co-Authored-By: Claude Opus 5.5 --- .../component/PasswordHealthStatus.kt | 24 ++ .../component/SeverityBreakdownBar.kt | 210 ++++++++++++++++++ .../presentation/component/SeverityRamp.kt | 95 ++++++++ .../model/PasswordHealthUiState.kt | 52 ++++- .../src/main/res/values/strings.xml | 5 + .../component/SeverityRampTest.kt | 128 +++++++++++ .../model/PasswordHealthUiStateTest.kt | 110 +++++++++ 7 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt create mode 100644 feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt create mode 100644 feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt index 70b8fccbe..3d7ea97a4 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/PasswordHealthStatus.kt @@ -12,6 +12,7 @@ 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.layout.size import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.Icon @@ -34,6 +35,7 @@ import de.davis.keygo.feature.password_health.R import de.davis.keygo.feature.password_health.domain.model.CheckGap import de.davis.keygo.feature.password_health.domain.model.GapReason import de.davis.keygo.feature.password_health.domain.model.PasswordHealthReportError +import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthStatus import de.davis.keygo.feature.password_health.presentation.model.PasswordHealthUiState import de.davis.keygo.feature.password_health.presentation.model.RunPhase import de.davis.keygo.feature.password_health.presentation.model.coverageNote @@ -56,6 +58,11 @@ internal fun PasswordHealthStatus( targetValue = contentColorFor(containerColorTarget), label = "contentColor" ) + val iconColor by animateColorAsState( + targetValue = if (state.isAccented) MaterialTheme.colorScheme.tertiary + else contentColorFor(containerColorTarget), + label = "iconColor" + ) KeyGoCard( title = { @@ -78,6 +85,7 @@ internal fun PasswordHealthStatus( false -> Icon( imageVector = state.status.icon(), contentDescription = null, + tint = iconColor, ) } } @@ -85,6 +93,22 @@ internal fun PasswordHealthStatus( ) { Text(text = state.verdict()) + AnimatedVisibility( + visible = !state.isFirstLoad && state.status == PasswordHealthStatus.NEEDS_ATTENTION, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + label = "breakdownVisibility" + ) { + SeverityBreakdownBar( + breakdown = state.breakdown, + containerColor = containerColorTarget, + accentColor = + if (state.isAccented) MaterialTheme.colorScheme.tertiary + else containerColorTarget, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + SupportingLine(text = state.detailLine(), label = "detailLineVisibility") SupportingLine(text = state.coverageNote(), label = "coverageNoteVisibility") SupportingLine(text = state.breachGap?.message(), label = "breachGapVisibility") diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt new file mode 100644 index 000000000..cd8425db3 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityBreakdownBar.kt @@ -0,0 +1,210 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.password_health.R +import de.davis.keygo.feature.password_health.presentation.model.SeverityBreakdown +import kotlin.math.min + +@Composable +internal fun SeverityBreakdownBar( + breakdown: SeverityBreakdown, + containerColor: Color, + accentColor: Color, + modifier: Modifier = Modifier, +) { + val ramp = remember(containerColor, accentColor) { severityRamp(containerColor, accentColor) } + val critical by animateColorAsState(ramp.critical, label = "criticalColor") + val high by animateColorAsState(ramp.high, label = "highColor") + val medium by animateColorAsState(ramp.medium, label = "mediumColor") + val segments = listOf( + Segment(breakdown.critical, critical, R.string.breakdown_critical), + Segment(breakdown.high, high, R.string.breakdown_high), + Segment(breakdown.medium, medium, R.string.breakdown_medium), + Segment( + breakdown.clean, + LocalContentColor.current.copy(alpha = TrackAlpha), + R.string.breakdown_clean, + ), + ) + val weights = segments.map { + animateFloatAsState(targetValue = it.count.toFloat(), label = "segmentWeight") + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clearAndSetSemantics {}, + ) { + drawSegments( + colors = segments.map { it.color }, + weights = weights.map { it.value }, + gap = 4.dp.toPx(), + ) + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + segments.filter { it.count > 0 }.forEach { + LegendEntry(it) + } + } + } +} + +private const val TrackAlpha = 0.14f + +private data class Segment( + val count: Int, + val color: Color, + @param:StringRes val label: Int, +) + +// A segment's minimum width (a dot) and trailing gap scale with its weight up to 1, so a single +// finding among hundreds stays visible and segments grow in and out without jumping. +private fun DrawScope.drawSegments(colors: List, weights: List, gap: Float) { + val total = weights.sum() + if (total <= 0f) return + + val presence = weights.map { it.coerceIn(0f, 1f) } + val presenceSum = presence.sum() + val available = size.width - gap * (presenceSum - presence.last { it > 0f }) + val free = available - presenceSum * size.height + + var x = 0f + weights.forEachIndexed { i, weight -> + if (weight <= 0f) return@forEachIndexed + + val width = + if (free >= 0f) presence[i] * size.height + free * weight / total + else available * weight / total + val left = if (layoutDirection == LayoutDirection.Rtl) size.width - x - width else x + + drawRoundRect( + color = colors[i], + topLeft = Offset(left, 0f), + size = Size(width, size.height), + cornerRadius = CornerRadius(min(width, size.height) / 2), + ) + x += width + gap * presence[i] + } +} + +@Composable +private fun LegendEntry(segment: Segment) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(segment.color, CircleShape), + ) + Text( + text = segment.legendText(), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun Segment.legendText(): AnnotatedString { + val text = stringResource(label, count) + + // stringResource does format the same way by taking the first locale + val number = "%d".format(LocalConfiguration.current.locales[0], count) + val start = text.indexOf(number) + + return buildAnnotatedString { + append(text) + if (start >= 0) addStyle( + style = SpanStyle(fontWeight = FontWeight.SemiBold), + start = start, + end = start + number.length, + ) + } +} + +@Composable +private fun BreakdownPreviewCards() { + Column { + listOf( + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ).forEach { container -> + Surface(color = container) { + SeverityBreakdownBar( + breakdown = SeverityBreakdown(critical = 1, high = 5, medium = 3, clean = 120), + containerColor = container, + accentColor = + if (container == MaterialTheme.colorScheme.surfaceContainerHigh) MaterialTheme.colorScheme.tertiary + else container, + modifier = Modifier.padding(16.dp), + ) + } + } + } +} + +@Preview +@Composable +private fun SeverityBreakdownBarPreview() { + MaterialTheme { + BreakdownPreviewCards() + } +} + +@Preview +@Composable +private fun SeverityBreakdownBarDarkPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + BreakdownPreviewCards() + } +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt new file mode 100644 index 000000000..d355f2a93 --- /dev/null +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRamp.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.luminance +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cbrt +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +internal data class SeverityRamp(val critical: Color, val high: Color, val medium: Color) + +internal const val CriticalContrast = 4.5f +internal const val HighContrast = 3.75f +internal const val MediumContrast = 3f + +private const val MaxChroma = 72f +private const val HighChroma = 0.75f +private const val MediumChroma = 0.5f +private const val ToneStep = 0.5f + +// Every step keeps the accent's hue (the card's own by default), so the ramp matches any dynamic +// scheme. Severity is carried mostly by vividness: critical is the most saturated the hue allows, +// the lower steps fade towards it. Contrast alone cannot do this, because the far end of a dark card +// is near white, where no hue survives. Neutral accents get a muted cap so a faint tint does not +// turn neon. +internal fun severityRamp(container: Color, accent: Color = container): SeverityRamp { + val lab = accent.convert(ColorSpaces.CieLab) + val hue = atan2(lab.blue, lab.green) + val cap = min(hypot(lab.green, lab.blue) * 6f, MaxChroma) + + val background = container.luminance() + 0.05f + val darker = background / 0.05f >= 1.05f / background + + // Gamut mapping and 8-bit rounding can cost a little contrast, so the result is measured and + // pushed further out until it holds. + fun step(ratio: Float, chroma: Float): Color { + val y = if (darker) background / ratio - 0.05f else ratio * background - 0.05f + var tone = yToTone(y.coerceIn(0f, 1f)) + var color = labColor(tone, chroma, hue) + while (contrast(color.luminance() + 0.05f, background) < ratio && tone in 0f..100f) { + tone += if (darker) -ToneStep else ToneStep + color = labColor(tone.coerceIn(0f, 100f), chroma, hue) + } + return color + } + + val critical = step(CriticalContrast, cap) + val criticalChroma = critical.chroma() + + return SeverityRamp( + critical = critical, + high = step(HighContrast, criticalChroma * HighChroma), + medium = step(MediumContrast, criticalChroma * MediumChroma), + ) +} + +internal fun Color.chroma(): Float { + val lab = convert(ColorSpaces.CieLab) + return hypot(lab.green, lab.blue) +} + +private fun contrast(a: Float, b: Float) = max(a, b) / min(a, b) + +private fun yToTone(y: Float): Float = + if (y > 216f / 24389f) 116f * cbrt(y) - 16f + else y * 24389f / 27f + +// Lowers chroma until the color fits sRGB, so clamping never shifts the tone the contrast relies on. +private fun labColor(tone: Float, chroma: Float, hue: Float): Color { + fun at(c: Float) = Color(tone, c * cos(hue), c * sin(hue), colorSpace = ColorSpaces.CieLab) + + val full = at(chroma) + if (full.fitsSrgb()) return full.convert(ColorSpaces.Srgb) + + var low = 0f + var high = chroma + repeat(12) { + val mid = (low + high) / 2 + if (at(mid).fitsSrgb()) low = mid + else high = mid + } + return at(low).convert(ColorSpaces.Srgb) +} + +private fun Color.fitsSrgb(): Boolean { + val back = convert(ColorSpaces.Srgb).convert(ColorSpaces.CieLab) + return abs(back.red - red) < 0.5f && + abs(back.green - green) < 0.5f && + abs(back.blue - blue) < 0.5f +} diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt index 569749987..2a1b919d1 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiState.kt @@ -77,6 +77,41 @@ internal fun List.summary(): HealthSummary { ) } +internal data class SeverityBreakdown( + val critical: Int, + val high: Int, + val medium: Int, + val clean: Int, +) + +internal fun List.breakdown(checked: Int): SeverityBreakdown { + val worst = mutableMapOf() + fun flag(id: ItemId, severity: FindingSeverity?) { + if (severity != null) worst.merge(id, severity, ::maxOf) + } + + forEach { section -> + section.standalone.forEach { flag(it.itemId, it.maxSeverity) } + section.groups.forEach { group -> + val memberIds = group.members.mapTo(mutableSetOf()) { it.itemId } + group.members.forEach { flag(it.itemId, it.maxSeverity) } + group.relations.forEach { relation -> + relation.relatedItemIds.forEach { + if (it in memberIds) flag(it, relation.type.severity) + } + } + } + } + + val counts = worst.values.groupingBy { it }.eachCount() + return SeverityBreakdown( + critical = counts[FindingSeverity.Critical] ?: 0, + high = counts[FindingSeverity.High] ?: 0, + medium = counts[FindingSeverity.Medium] ?: 0, + clean = (checked - worst.size).coerceAtLeast(0), + ) +} + internal data class HealthSection( val severity: FindingSeverity, val groups: List, @@ -164,6 +199,10 @@ internal data class PasswordHealthUiState( val summary by lazy { healthSections.summary() } + val breakdown by lazy { healthSections.breakdown(totalPasswordCount - unreadable.size) } + + val worstSeverity: FindingSeverity? = healthSections.firstOrNull()?.severity + val breachGap: CheckGap? = checkGaps[CheckKind.Breach] val status = when { @@ -174,6 +213,12 @@ internal data class PasswordHealthUiState( healthSections.isNotEmpty() -> PasswordHealthStatus.NEEDS_ATTENTION else -> PasswordHealthStatus.ALL_GOOD } + + // Medium findings keep a neutral card and carry their tone in an accent, so the card reads + // calmer than a high one without looking disabled. + val isAccented = !isFirstLoad && + status == PasswordHealthStatus.NEEDS_ATTENTION && + worstSeverity == FindingSeverity.Medium } @Composable @@ -183,7 +228,12 @@ internal fun PasswordHealthUiState.toneColor(): Color { return when (status) { PasswordHealthStatus.NO_DATA -> MaterialTheme.colorScheme.surfaceContainerHigh PasswordHealthStatus.ALL_GOOD -> MaterialTheme.colorScheme.secondaryContainer - PasswordHealthStatus.NEEDS_ATTENTION, + PasswordHealthStatus.NEEDS_ATTENTION -> when (worstSeverity) { + FindingSeverity.Critical, null -> MaterialTheme.colorScheme.errorContainer + FindingSeverity.High -> MaterialTheme.colorScheme.tertiaryContainer + FindingSeverity.Medium -> MaterialTheme.colorScheme.surfaceContainerHigh + } + PasswordHealthStatus.FAILED -> MaterialTheme.colorScheme.errorContainer } } diff --git a/feature/password-health/src/main/res/values/strings.xml b/feature/password-health/src/main/res/values/strings.xml index ab513c94e..0e94fb5a5 100644 --- a/feature/password-health/src/main/res/values/strings.xml +++ b/feature/password-health/src/main/res/values/strings.xml @@ -29,6 +29,11 @@ %1$d reused %1$d similar + %1$d critical + %1$d high + %1$d to review + %1$d fine + Needs attention Weak Reused diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt new file mode 100644 index 000000000..4274df5b4 --- /dev/null +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/component/SeverityRampTest.kt @@ -0,0 +1,128 @@ +package de.davis.keygo.feature.password_health.presentation.component + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.luminance +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.min +import kotlin.test.Test +import kotlin.test.assertTrue + +class SeverityRampTest { + + // errorContainer, tertiaryContainer and surfaceContainerHighest of tonal-spot schemes for six + // wallpapers, light and dark, under both the 2021 and the 2025 spec. + private val containers = listOf( + 0xFF1F4D52, 0xFF21281F, 0xFF23252E, 0xFF2A2519, 0xFF2C232A, 0xFF2F4E34, 0xFF302221, + 0xFF30231C, 0xFF323630, 0xFF33343A, 0xFF38342B, 0xFF3A3337, 0xFF3D3231, 0xFF3D332D, + 0xFF49491E, 0xFF584419, 0xFF5A3D58, 0xFF663C2F, 0xFF85230A, 0xFF871C34, 0xFF871F21, + 0xFF93000A, 0xFFBCEBF0, 0xFFC8ECC9, 0xFFDEE5D8, 0xFFDFCCFD, 0xFFE0E4DB, 0xFFE1E2ED, + 0xFFE3E2E9, 0xFFE8E5AC, 0xFFE9CDFD, 0xFFEAE2D4, 0xFFECDFE5, 0xFFECE1CE, 0xFFEDEFAC, + 0xFFEEDEE8, 0xFFF0DFD7, 0xFFF1DEDC, 0xFFF5DED2, 0xFFF6DDDA, 0xFFF97386, 0xFFF9C98E, + 0xFFFA746F, 0xFFFAE8A2, 0xFFFCFEB9, 0xFFFD795A, 0xFFFEC392, 0xFFFEDEA6, 0xFFFFD6F8, + 0xFFFFDAD6, 0xFFFFDBD0, + ).map { Color(it) } + + @Test + fun everyStepReachesItsContrastOnEveryCard() { + containers.forEach { container -> + val ramp = severityRamp(container) + listOf( + ramp.critical to CriticalContrast, + ramp.high to HighContrast, + ramp.medium to MediumContrast, + ).forEach { (color, target) -> + val ratio = contrast(color, container) + assertTrue(ratio >= target, "$color on $container is $ratio, needs $target") + } + } + } + + @Test + fun anAccentTintsANeutralCardAndStillReachesItsContrast() { + val cards = listOf(0xFFF0EAF1, 0xFF2B292D).map { Color(it) } + val accents = listOf(0xFF7D5260, 0xFFEFB8C8, 0xFF625B71, 0xFF006A6A).map { Color(it) } + cards.forEach { card -> + accents.forEach { accent -> + val ramp = severityRamp(card, accent) + listOf( + ramp.critical to CriticalContrast, + ramp.high to HighContrast, + ramp.medium to MediumContrast, + ).forEach { (color, target) -> + val ratio = contrast(color, card) + assertTrue(ratio >= target, "$color on $card is $ratio, needs $target") + val drift = hueDistance(hue(color), hue(accent)) + assertTrue(drift < 10.0, "$color drifted $drift degrees from $accent") + } + } + } + } + + @Test + fun criticalStandsOutMostAndMediumLeast() { + containers.forEach { container -> + val ramp = severityRamp(container) + val critical = contrast(ramp.critical, container) + val high = contrast(ramp.high, container) + val medium = contrast(ramp.medium, container) + assertTrue(critical > high && high > medium, "ramp on $container is out of order") + } + } + + @Test + fun criticalIsTheMostVividAndMediumTheCalmest() { + containers.forEach { container -> + val ramp = severityRamp(container) + assertTrue( + ramp.critical.chroma() > ramp.high.chroma() && + ramp.high.chroma() > ramp.medium.chroma(), + "vividness on $container is out of order", + ) + } + } + + @Test + fun aNeutralCardKeepsTheRampMuted() { + val ramp = severityRamp(Color(0xFFECDFE5)) + assertTrue(ramp.critical.chroma() < 40f, "${ramp.critical} is too saturated") + } + + @Test + fun aGreyCardGetsAGreyRamp() { + val ramp = severityRamp(Color(0xFFEBEBEB)) + listOf(ramp.critical, ramp.high, ramp.medium).forEach { + assertTrue(it.chroma() < 2f, "$it is tinted") + } + } + + @Test + fun theRampKeepsTheCardsHue() { + listOf(0xFFFFDAD6, 0xFF93000A, 0xFFDFCCFD, 0xFFC8ECC9).map { Color(it) }.forEach { card -> + val ramp = severityRamp(card) + listOf(ramp.critical, ramp.high, ramp.medium).forEach { color -> + val drift = hueDistance(hue(color), hue(card)) + assertTrue(drift < 10.0, "$color drifted $drift degrees from $card") + } + } + } + + private fun contrast(a: Color, b: Color): Float { + val la = a.luminance() + 0.05f + val lb = b.luminance() + 0.05f + return max(la, lb) / min(la, lb) + } + + private fun hue(color: Color): Double { + val lab = color.convert(ColorSpaces.CieLab) + return atan2(lab.blue.toDouble(), lab.green.toDouble()) * 180 / PI + } + + private fun hueDistance(a: Double, b: Double): Double { + val d = abs(a - b) % 360 + return min(d, 360 - d) + } +} diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt index 19966f8c0..95811fd29 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/model/PasswordHealthUiStateTest.kt @@ -354,6 +354,116 @@ class PasswordHealthUiStateTest { assertEquals(1, state.summary.weak) } + @Test + fun theWorstSeverityIsTheTopSection() { + val state = PasswordHealthUiState( + totalPasswordCount = 3, + reportedSections = listOf( + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(0), item(1))), + ), + section(FindingSeverity.Medium, standalone = listOf(item(2, weak))), + ), + ) + + assertEquals(FindingSeverity.High, state.worstSeverity) + } + + @Test + fun theWorstSeverityDropsOnceItsItemsAreFixed() { + val state = PasswordHealthUiState( + totalPasswordCount = 2, + reportedSections = listOf( + section(FindingSeverity.Critical, standalone = listOf(item(0, breached))), + section(FindingSeverity.Medium, standalone = listOf(item(1, weak))), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals(FindingSeverity.Medium, state.worstSeverity) + } + + @Test + fun nothingFlaggedHasNoWorstSeverity() { + assertNull(PasswordHealthUiState(totalPasswordCount = 3).worstSeverity) + } + + @Test + fun theBreakdownPutsEachPasswordUnderItsWorstIssue() { + val sections = listOf( + section( + FindingSeverity.Critical, + groups = listOf( + group(RelationType.Similar, item(0, breached), item(1), item(2, weak)), + ), + standalone = listOf(item(3, breached, weak)), + ), + section( + FindingSeverity.High, + groups = listOf(group(RelationType.Reused, item(4, weak), item(5))), + ), + section(FindingSeverity.Medium, standalone = listOf(item(6, weak))), + ) + + assertEquals( + SeverityBreakdown(critical = 2, high = 2, medium = 3, clean = 3), + sections.breakdown(checked = 10), + ) + } + + @Test + fun aGroupMemberTakesTheSeverityOfItsOwnRelationOnly() { + val mixed = RelatedGroup( + id = id(0), + members = (0..2).mapTo(mutableSetOf()) { item(it) }, + relations = setOf( + HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused), + HealthFinding.Relation(setOf(id(1), id(2)), RelationType.Similar), + ), + ) + + assertEquals( + SeverityBreakdown(critical = 0, high = 2, medium = 1, clean = 0), + listOf(section(FindingSeverity.High, groups = listOf(mixed))).breakdown(checked = 3), + ) + } + + @Test + fun unreadablePasswordsAreLeftOutOfTheBreakdown() { + val state = PasswordHealthUiState( + totalPasswordCount = 5, + reportedSections = listOf( + section(FindingSeverity.Medium, standalone = listOf(item(0, weak))), + ), + unreadable = setOf(id(8), id(9)), + ) + + assertEquals( + SeverityBreakdown(critical = 0, high = 0, medium = 1, clean = 2), + state.breakdown, + ) + } + + @Test + fun aFixedPasswordMovesToClean() { + val state = PasswordHealthUiState( + totalPasswordCount = 4, + reportedSections = listOf( + section( + FindingSeverity.Critical, + standalone = listOf(item(0, breached), item(1, breached)), + ), + ), + optimisticallyFixed = setOf(id(0)), + ) + + assertEquals( + SeverityBreakdown(critical = 1, high = 0, medium = 0, clean = 3), + state.breakdown, + ) + } + private fun id(n: Int): ItemId = UUID(0L, n.toLong()) private fun item(n: Int, vararg issues: ItemIssue, title: String = "item-$n") = ItemHealth( From 3dc1b5bc1e2b3cee26eb92b9d0f952e062d85bfd Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 2 Oct 2026 00:55:23 +0200 Subject: [PATCH 53/54] fix: resolve leftovers from merging v2 --- .../davis/keygo/feature/item/view/login/ViewLoginViewModel.kt | 3 +-- .../keygo/feature/list_screen/domain/usecase/FilterUseCase.kt | 2 ++ .../domain/usecase/ObserveFilterResultUseCaseTest.kt | 4 +--- .../feature/list_screen/presentation/ItemListViewModelTest.kt | 3 +-- .../presentation/PasswordHealthViewModelTest.kt | 2 ++ 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index d8796f234..0ddfddaba 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -12,7 +12,6 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.comparator.sortedNaturallyBy -import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isSuccess @@ -110,7 +109,7 @@ internal class ViewLoginViewModel( password = obfuscated, passwordStrengthScore = login.passwordCredential?.score, username = login.username.orEmpty(), - domains = login.domainInfos, + domains = login.domainInfos.mapTo(mutableSetOf()) { it.value }, tags = login.tags.sortedNaturallyBy { it.display }.toSet(), note = login.note.orEmpty(), totpState = TotpState.NoTotp, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index b5fb2ea32..c6395dbb7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.util.domain.comparator.NaturalOrderComparator import de.davis.keygo.feature.list_screen.domain.model.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -34,6 +35,7 @@ class FilterUseCase { SortDirection.Ascending -> NaturalOrderComparator SortDirection.Descending -> NaturalOrderComparator.reversed() } + private fun FilterFacet.matches( item: LiteItem, selections: FacetSelections, diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt index 1cc289768..28168362d 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -18,7 +18,6 @@ import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.passkeyRef -import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterResult import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -40,12 +39,11 @@ class ObserveFilterResultUseCaseTest { private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) private val creditCardRepository = FakeCreditCardRepository() - private val sortUseCase = SortUseCase() private val useCase = ObserveFilterResultUseCase( itemRepository = itemRepository, loginRepository = loginRepository, - filterUseCase = FilterUseCase(sortUseCase), + filterUseCase = FilterUseCase(), observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ) diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index 7ea1e8034..ce89abc74 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt @@ -73,7 +73,6 @@ class ItemListViewModelTest { enableSelection = enableSelection, restrictedItemType = null, itemRepository = itemRepository, - filterUseCase = FilterUseCase(), rankSearchResults = RankSearchResultsUseCase(), observeAllTags = ObserveAllTagsSortedUseCase(itemRepository), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( @@ -83,7 +82,7 @@ class ItemListViewModelTest { observeFilterResult = ObserveFilterResultUseCase( itemRepository = itemRepository, loginRepository = loginRepository, - filterUseCase = filterUseCase, + filterUseCase = FilterUseCase(), observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ), diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt index 966efb2c9..fdbecf3ab 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/presentation/PasswordHealthViewModelTest.kt @@ -13,6 +13,7 @@ import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.Result import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.view.domain.WebsiteHandler @@ -128,6 +129,7 @@ class PasswordHealthViewModelTest { upsertVaultItem = UpsertVaultItemUseCase(loginRepository, FakeCreditCardRepository()), passwordStrengthEstimator = FakePasswordStrengthEstimator(PasswordScore.Strong), totpService = FakeTotpService(), + registrableDomainResolver = FakeRegistrableDomainResolver(), ), healthSettingsRepository = breachState, setHealthNotifications = SetHealthNotificationsUseCase( From 613b5a6cf4fdb41015a8452ed84bab93d5b91446 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 2 Oct 2026 01:39:27 +0200 Subject: [PATCH 54/54] test: wait for the view model scope to finish in ViewCreditCardViewModelTest The view model collects on Dispatchers.Default. Cancelling viewModelScope without joining let that work finish after resetMain(), which then dispatched to the real Android main looper and failed the test on CI. --- .../item/view/creditcard/ViewCreditCardViewModelTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 799cc9233..2c084ed33 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -23,8 +23,9 @@ import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.rust.FakeCardFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -141,7 +142,7 @@ class ViewCreditCardViewModelTest { try { return vm.state.first { it.name.isNotEmpty() } } finally { - vm.viewModelScope.cancel() + vm.viewModelScope.coroutineContext.job.cancelAndJoin() } } @@ -151,7 +152,7 @@ class ViewCreditCardViewModelTest { val state = vm.state.first { it.cardNumber != null } return state.cardNumber!! } finally { - vm.viewModelScope.cancel() + vm.viewModelScope.coroutineContext.job.cancelAndJoin() } }