Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e712aaf
feat: add password-health module
OffRange Sep 15, 2026
b69a2a8
feat: add first prototype UI
OffRange Sep 17, 2026
73936fa
feat: add weak health check prototype
OffRange Sep 18, 2026
3bfd00e
feat: add reuse check
OffRange Sep 18, 2026
db4e7bd
fix: dominantRelation
OffRange Sep 19, 2026
3ae314f
fix: add similar count label
OffRange Sep 19, 2026
be9f1e4
feat: make checkers return result
OffRange Sep 19, 2026
ccce389
feat: add pull to refresh
OffRange Sep 20, 2026
94054d9
feat: add breached check
OffRange Sep 20, 2026
8954e4d
feat: add breach check state protobuf
OffRange Sep 20, 2026
13fc53f
refactor: change check return type
OffRange Sep 20, 2026
49e6204
refactor: loading logic
OffRange Sep 20, 2026
bdc810a
feat: wire up breach switch
OffRange Sep 20, 2026
66f5953
feat: add fix password feature
OffRange Sep 20, 2026
33dd393
feat: add similarity checker
OffRange Sep 21, 2026
15750fb
feat: add open item support
OffRange Sep 21, 2026
081b77a
refactor: rename package
OffRange Sep 24, 2026
404daa8
feat: add report store
OffRange Sep 24, 2026
608f070
feat: add account scope
OffRange Sep 25, 2026
c2298d7
feat: add health report store repository
OffRange Sep 25, 2026
b135287
feat: add fingerprinter
OffRange Sep 25, 2026
f176595
fix: health store compression
OffRange Sep 25, 2026
8b21197
feat: cache report
OffRange Sep 25, 2026
d4105f0
test: drop removed PasswordCandidate fields from fixtures
OffRange Sep 25, 2026
fe6335b
fix: keep checker gaps in the health report
OffRange Sep 25, 2026
3c1e4b7
refactor: split health report scanning and assembly out of the use case
OffRange Sep 25, 2026
f8d8da1
feat: invalidate when gap has failed reason
OffRange Sep 25, 2026
c4ef734
refactor: make scanner more readable
OffRange Sep 25, 2026
8dd7269
chore: add comment
OffRange Sep 25, 2026
9267411
fix: dont fail report when cache failed
OffRange Sep 25, 2026
b376d54
test: pass vault manager to crypto scope provider in tests
OffRange Sep 25, 2026
2428c3a
test: expand password health test coverage
OffRange Sep 25, 2026
c5c7977
fix: treat a corrupted stored health report as a cache miss
OffRange Sep 26, 2026
c4f5c1b
feat: force full scan
OffRange Sep 26, 2026
cf51091
feat: run health check on every app open
OffRange Sep 26, 2026
75309d4
feat: check connectivity bevor performing breach check
OffRange Sep 26, 2026
402215e
feat: add notifier
OffRange Sep 26, 2026
40626f9
refactor: turn breach check state into health settings with a notific…
OffRange Sep 26, 2026
c728f5d
feat: add notification support
OffRange Sep 27, 2026
a824061
feat: improve notification icon
OffRange Sep 27, 2026
294781a
feat: open health screen when notification tapped
OffRange Sep 27, 2026
04d4c8f
refactor: resolve todo
OffRange Sep 27, 2026
a8ce7da
fix: improve performance
OffRange Sep 27, 2026
5d35844
feat: show breach error status in card
OffRange Sep 28, 2026
e3e524a
feat: reduce timeout to 3 sec
OffRange Sep 28, 2026
9567183
feat: update connectivity checks to use Flow for internet status
OffRange Sep 28, 2026
48d2f56
feat: cluster standalone items by their worst issue and update UI acc…
OffRange Sep 28, 2026
377c7e6
fix: clarify that the breach check failed in gap messages
OffRange Sep 28, 2026
671ca66
refactor: introduce natural order comparator
OffRange Sep 30, 2026
3029000
refactor: improve ordering performance
OffRange Sep 30, 2026
6a59506
feat: sort health findings
OffRange Sep 30, 2026
10ba391
feat: add severity breakdown bar for password health status
OffRange Sep 30, 2026
0f07b50
Merge branch 'v2' into feat/password-health
OffRange Oct 1, 2026
3dc1b5b
fix: resolve leftovers from merging v2
OffRange Oct 1, 2026
613b5a6
test: wait for the view model scope to finish in ViewCreditCardViewMo…
OffRange Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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)
}


Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,33 @@ 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.ACTION_OPEN_PASSWORD_HEALTH
import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute
import de.davis.keygo.feature.settings.presentation.SettingsRoute

/** The navigation bar's destinations. */
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,
ACTION_OPEN_PASSWORD_HEALTH,
),
CONNECTIVITY(
RouteDestination.Connectivity,
R.string.connectivity,
Expand All @@ -31,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<NavKey> = AppDestinations.entries.mapTo(LinkedHashSet()) { it.route }
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ 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
import de.davis.keygo.legacy_migration.domain.usecase.HasMainPasswordUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -17,6 +19,7 @@ internal class AppViewModel(
private val accountRepository: AccountRepository,
private val hasV1Password: HasMainPasswordUseCase,
session: Session,
passwordHealthAttention: PasswordHealthAttention,
) : ViewModel() {

private val _isReturningUser = MutableStateFlow<Boolean?>(null)
Expand All @@ -28,9 +31,23 @@ internal class AppViewModel(
*/
val isSessionActive: StateFlow<Boolean> = session.isActive

val needsAttentionCount: StateFlow<Int> = passwordHealthAttention.needsAttention

private val _requestedTopLevelRoute = MutableStateFlow<NavKey?>(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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -61,6 +64,8 @@ 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()
val requestedTopLevelRoute by viewModel.requestedTopLevelRoute.collectAsState()

KeyGoTheme {
val snackbarManager = koinInject<SnackbarManager>()
Expand All @@ -71,12 +76,20 @@ class MainActivity : FragmentActivity() {
hasAccess = hasAccess,
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()
}
Expand All @@ -93,6 +106,9 @@ private fun App(
hasAccess: Boolean,
launchRoute: NavKey,
isSessionActive: Boolean,
needsAttentionCount: Int,
requestedTopLevelRoute: NavKey?,
onTopLevelRouteRequestHandled: () -> Unit,
) {
val navigationState = rememberAppNavigationState(
launchRoute = launchRoute,
Expand All @@ -103,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)
Expand Down Expand Up @@ -130,6 +152,7 @@ private fun App(
onItemSelected = { type -> navigator.showDetail(RouteDestination.CreateItem(type)) },
showChrome = shell.showNavigation,
showPrimaryActionButton = shell.showCreateButton,
needsAttentionCount = needsAttentionCount,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,7 +86,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
Expand All @@ -100,6 +104,7 @@ import de.davis.keygo.core.ui.composition.LocalNavigationBarCollapseState
import de.davis.keygo.core.ui.composition.NavigationBarCollapseState
import kotlinx.coroutines.launch
import de.davis.keygo.core.ui.R as CoreUiR
import de.davis.keygo.feature.password_health.R as PasswordHealthR


@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
Expand All @@ -111,6 +116,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,
Expand Down Expand Up @@ -170,6 +176,7 @@ fun KeyGoNavigationWrapper(
drawerState.close()
}
},
needsAttentionCount = needsAttentionCount,
buttonContainerColor = buttonContainerColor,
buttonContentColor = buttonContentColor
)
Expand All @@ -191,6 +198,7 @@ fun KeyGoNavigationWrapper(
drawerState.open()
}
},
needsAttentionCount = needsAttentionCount,
buttonContainerColor = buttonContainerColor,
buttonContentColor = buttonContentColor,
)
Expand Down Expand Up @@ -331,6 +339,7 @@ fun KeyGoNavigationSuite(
navigateToTopLvlDestination: (NavKey) -> Unit,
onButtonClicked: () -> Unit,
onOpenDrawer: () -> Unit,
needsAttentionCount: Int = 0,
buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor,
buttonContentColor: Color = contentColorFor(buttonContainerColor),
) {
Expand All @@ -339,6 +348,7 @@ fun KeyGoNavigationSuite(
KeyGoNavigationBar(
selectedRoute = selectedRoute,
navigateToTopLvlDestination = navigateToTopLvlDestination,
needsAttentionCount = needsAttentionCount,
)
}

Expand All @@ -348,6 +358,7 @@ fun KeyGoNavigationSuite(
navigateToTopLvlDestination = navigateToTopLvlDestination,
onButtonClicked = onButtonClicked,
onOpenDrawer = onOpenDrawer,
needsAttentionCount = needsAttentionCount,
buttonContainerColor = buttonContainerColor,
buttonContentColor = buttonContentColor
)
Expand All @@ -358,6 +369,7 @@ fun KeyGoNavigationSuite(
selectedRoute = selectedRoute,
onButtonClicked = onButtonClicked,
navigateToTopLvlDestination = navigateToTopLvlDestination,
needsAttentionCount = needsAttentionCount,
buttonContainerColor = buttonContainerColor,
buttonContentColor = buttonContentColor
)
Expand All @@ -371,18 +383,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
)
Expand All @@ -396,6 +404,7 @@ fun KeyGoNavigationRail(
navigateToTopLvlDestination: (NavKey) -> Unit,
onButtonClicked: () -> Unit,
onOpenDrawer: () -> Unit,
needsAttentionCount: Int = 0,
buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor,
buttonContentColor: Color = contentColorFor(buttonContainerColor)
) {
Expand Down Expand Up @@ -434,12 +443,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
)
Expand All @@ -453,6 +457,7 @@ fun KeyGoNavigationDrawer(
selectedRoute: NavKey?,
navigateToTopLvlDestination: (NavKey) -> Unit,
onButtonClicked: () -> Unit,
needsAttentionCount: Int = 0,
buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor,
buttonContentColor: Color = contentColorFor(buttonContainerColor)
) {
Expand All @@ -463,6 +468,7 @@ fun KeyGoNavigationDrawer(
selectedRoute = selectedRoute,
navigateToTopLvlDestination = navigateToTopLvlDestination,
onButtonClicked = onButtonClicked,
needsAttentionCount = needsAttentionCount,
buttonContainerColor = buttonContainerColor,
buttonContentColor = buttonContentColor
)
Expand All @@ -475,6 +481,7 @@ fun DrawerContent(
navigateToTopLvlDestination: (NavKey) -> Unit,
onButtonClicked: () -> Unit,
onCloseDrawer: (() -> Unit)? = null,
needsAttentionCount: Int = 0,
buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor,
buttonContentColor: Color = contentColorFor(buttonContainerColor)
) {
Expand Down Expand Up @@ -545,12 +552,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,
Expand Down
Loading
Loading