Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3d4e257
feat(security): end the session when the app leaves the foreground
OffRange Sep 2, 2026
bbbdcd1
feat(app): expose a transition-only isLocked signal from AppViewModel
OffRange Sep 2, 2026
d99891f
fix(app): keep isLocked's upstream collection alive across UI subscri…
OffRange Sep 2, 2026
65d09fa
feat(app): add AppNavigator.lock/unlock and make goBack respect the gate
OffRange Sep 2, 2026
f966b9a
feat(app): lock on background and unlock through the navigator's gate
OffRange Sep 2, 2026
7f20284
feat(settings): clear change-password fields when the session ends
OffRange Sep 2, 2026
2d15476
fix: make the app-lock gate survive configuration change and process …
OffRange Sep 2, 2026
b3158b2
fix: clear TextFieldState undo history when clearing sensitive passwo…
OffRange Sep 2, 2026
9e65e40
refactor(app): derive the lock gate from the back stack instead of sa…
OffRange Sep 2, 2026
97ca92d
refactor(app): rename the launch stack to the overlay
OffRange Sep 2, 2026
67ea139
refactor(app): stop truncating the other tabs when locking
OffRange Sep 2, 2026
de4e7a0
fix(app): take first run down without unlock so the gate guard holds
OffRange Sep 2, 2026
f8f4011
docs(app): shorten the navigation comments
OffRange Sep 2, 2026
e0561cd
refactor(app): gate on the session state alone and drop the transitio…
OffRange Sep 2, 2026
7988ed2
refactor(app): implement gate checks to prevent navigation while locked
OffRange Sep 3, 2026
e852cd5
refactor(app): replace direct ark access with withArkOr for session h…
OffRange Sep 3, 2026
086c72c
feat(app): implement SystemHandoff for session management and activit…
OffRange Sep 4, 2026
fd860ef
feat(security): introduce lock info storage
OffRange Sep 4, 2026
c1f18d2
feat(security): introduce elapsed time provider
OffRange Sep 4, 2026
24aa8f8
feat(security): introduce RecordBackgroundedAtUseCase.kt
OffRange Sep 4, 2026
3b9427e
feat: add auto lock picker
OffRange Sep 5, 2026
2ebd2ca
feat(di): introduce AppScopeQualifier and provideAppScope for Corouti…
OffRange Sep 5, 2026
d10824d
refactor(security): remove backgroundedAt from LockInfo and related c…
OffRange Sep 5, 2026
d28572f
feat(security): introduce in-memory session clock
OffRange Sep 5, 2026
308014a
feat(security): enhance session management with auto-lock timeout han…
OffRange Sep 5, 2026
64cd5e9
refactor(navigation): implement gated navigation checks for route man…
OffRange Sep 5, 2026
ddaf287
refactor(security): restructure ARK management with Generation class …
OffRange Sep 5, 2026
29bfb8b
refactor(security): enhance error handling
OffRange Sep 5, 2026
5bd26cd
refactor(security): improve session locking logic with synchronized a…
OffRange Sep 5, 2026
501af4c
refactor(security): enhance LockInfo mapping
OffRange Sep 5, 2026
7148fdd
test(security): add tests for ark lifecycle and session management be…
OffRange Sep 5, 2026
8dbeb34
refactor: cleanup
OffRange Sep 6, 2026
971b008
refactor(security): streamline backup ark provisioning with improved …
OffRange Sep 6, 2026
1edfd4b
refactor(security): enhance session locking logic and improve timeout…
OffRange Sep 6, 2026
58f7554
doc: fix doc
OffRange Sep 6, 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
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ internal class AppViewModel(
val isReturningUser = _isReturningUser.asStateFlow()

/**
* A restored back stack can hand the app proper the window straight after process death,
* skipping the launch flow; the fresh process's [Session] is never unlocked in that case, and
* nothing routes back to the unlock on its own once the launch stack has been emptied.
* [MainActivity] observes this and redirects whenever it goes false.
* The session's raw state, which [MainActivity] gates on: no ARK means the user has to
* authenticate again, whether the session just ended or a restored process never had one.
*/
val isSessionActive: StateFlow<Boolean> = session.isActive

Expand All @@ -35,4 +33,4 @@ internal class AppViewModel(
_isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() }
}
}
}
}
34 changes: 15 additions & 19 deletions app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,19 @@ private fun Intent.totpImportRedirect(): TotpImportRedirect? {

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun App(hasAccess: Boolean, launchRoute: NavKey, isSessionActive: Boolean) {
private fun App(
hasAccess: Boolean,
launchRoute: NavKey,
isSessionActive: Boolean,
) {
val navigationState = rememberAppNavigationState(
launchRoute = launchRoute,
startRoute = RouteDestination.Home,
topLevelRoutes = TopLevelRoutes,
)
val navigator = remember(navigationState) { AppNavigator(navigationState) }

RedirectToAuthWhenSessionEnds(isSessionActive, navigator)
LockAppWhenSessionEnds(isSessionActive, navigator)

val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
val directive = remember(windowAdaptiveInfo) {
Expand Down Expand Up @@ -140,25 +144,17 @@ private fun App(hasAccess: Boolean, launchRoute: NavKey, isSessionActive: Boolea
}

/**
* The navigation state outlives the process, so a restored back stack can hand the app proper the
* window again without the launch flow ever running. The fresh process has no unlocked session in
* that case, and nothing routes back to the unlock on its own once
* [AppNavigator.finishLaunchFlow] has emptied the launch stack, so the unlock is put back on top
* here.
*
* Keyed on the launch state as well, so this only acts while the app proper owns the window, and
* so a session that dies later (an auto lock, say) redirects at once rather than waiting for the
* next navigation. Onboarding and the unlock itself both run with no session by design, and
* redirecting there would take a first run user straight back out of setup.
* No ARK means the user has to authenticate again, so the session's own state is the whole rule -
* a session that just ended and a restored process that never had one are the same thing here.
* [AppNavigator.lock] is what knows when a gate would be wrong, so onboarding and the deep link
* redirect need no special case. Keyed on what the overlay is showing as well, so it re-decides
* when that changes under a session that is still ended.
*/
@Composable
private fun RedirectToAuthWhenSessionEnds(
isSessionActive: Boolean,
navigator: AppNavigator,
) {
val isLaunching = navigator.state.isLaunching
LaunchedEffect(isSessionActive, isLaunching) {
if (!isSessionActive && !isLaunching) navigator.replaceLaunchFlow(AuthRoute())
private fun LockAppWhenSessionEnds(isSessionActive: Boolean, navigator: AppNavigator) {
val topOverlayRoute = navigator.state.overlayStack.lastOrNull()
LaunchedEffect(isSessionActive, topOverlayRoute) {
if (!isSessionActive) navigator.lock()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import de.davis.keygo.core.ui.navigation.rememberNavEntryDecorators
/**
* Creates the app's navigation state. It survives configuration changes and process death.
*
* @param launchRoute what the launch flow starts on. Only used the first time the state is
* created; after that the saved stack wins.
* @param launchRoute what the overlay starts on. Only used the first time the state is created;
* after that the saved stack wins.
* @param startRoute the top level route the app opens on. Must be one of [topLevelRoutes].
* @param topLevelRoutes the navigation bar's destinations, one back stack each.
*/
Expand All @@ -38,12 +38,12 @@ fun rememberAppNavigationState(
mutableStateOf(startRoute)
}

val launchStack = rememberNavBackStack(launchRoute)
val overlayStack = rememberNavBackStack(launchRoute)
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }

return remember(startRoute, topLevelRoutes) {
AppNavigationState(
launchStack = launchStack,
overlayStack = overlayStack,
topLevelRoute = topLevelRoute,
backStacks = backStacks,
)
Expand All @@ -53,34 +53,33 @@ fun rememberAppNavigationState(
/**
* The app's navigation state, modified through [AppNavigator]. It holds two things:
*
* - The **launch stack**, carrying whatever has to happen before the app proper: unlocking, first
* run, or importing an incoming `otpauth://` link. While it holds anything it is all that shows.
* - The **overlay stack**: the unlock gate, first run, or an incoming `otpauth://` link. While it
* holds anything it is all that shows.
* - One **back stack per top level route**, each keeping its own history. Only the selected one is
* shown, with nothing underneath it, so back out of its base leaves the app.
*/
class AppNavigationState(
val launchStack: NavBackStack<NavKey>,
val overlayStack: NavBackStack<NavKey>,
topLevelRoute: MutableState<NavKey>,
val backStacks: Map<NavKey, NavBackStack<NavKey>>,
) {

/** The selected navigation bar destination. */
var topLevelRoute: NavKey by topLevelRoute

/** Whether the launch flow still owns the window. */
val isLaunching: Boolean get() = launchStack.isNotEmpty()
/** Whether the overlay owns the window, hiding the app proper underneath. */
val isOverlaid: Boolean get() = overlayStack.isNotEmpty()

/** The stack destinations are currently pushed onto and popped from. */
val currentStack: NavBackStack<NavKey>
get() = if (isLaunching) launchStack else backStacks.getValue(topLevelRoute)
get() = if (isOverlaid) overlayStack else backStacks.getValue(topLevelRoute)

/**
* What the detail pane is showing, or null while the list has the window to itself.
*
* A dialog is pushed onto the same stack but is drawn over the pane rather than taking it, so
* it is looked past. Reporting nothing while one is open makes the list pick a row on its own
* and push it above the dialog, which closes the dialog and leaves the pane the only thing the
* scene knows about.
* A dialog is pushed onto the same stack but drawn over the pane, so it is looked past.
* Reporting nothing while one is open makes the list pick a row and push it above the dialog,
* closing it.
*/
val openDetail: RouteDestination.Detail?
get() = currentStack.filterIsInstance<RouteDestination.Detail>().lastOrNull()
Expand All @@ -93,12 +92,12 @@ class AppNavigationState(
fun toDecoratedEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>,
): List<NavEntry<NavKey>> {
val launchEntries = rememberDecoratedEntries(launchStack, entryProvider)
val overlayEntries = rememberDecoratedEntries(overlayStack, entryProvider)
val topLevelEntries = backStacks.mapValues { (_, stack) ->
rememberDecoratedEntries(stack, entryProvider)
}

return if (isLaunching) launchEntries
return if (isOverlaid) overlayEntries
else topLevelEntries.getValue(topLevelRoute)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,46 @@ package de.davis.keygo.app.presentation.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
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.totp.presentation.TotpImportRedirect

/**
* Handles navigation events by updating [AppNavigationState]. Everything the UI can do to the back
* stacks goes through here, so the rules for what replaces what live in one place.
*
* Every entry point the UI can reach refuses to run while the gate is up. The chrome does not
* vanish the instant [lock] fires, it animates out, so the navigation bar and the create button
* stay composed and clickable for a moment behind the gate. Without the guard a tap in that window
* would push its destination onto the overlay, above the gate, and show it unauthenticated.
*/
class AppNavigator(val state: AppNavigationState) {

fun navigate(route: NavKey) {
val isTopLevel = !state.isLaunching && route in state.backStacks
/**
* True while [lock]'s gate is the overlay's top entry. Derived, so a restored gate reports
* itself. The type is what decides it: the `otpauth://` import shares this stack, so neither
* emptiness nor depth tells a gate from an import screen back may legitimately pop.
*/
private val isGated: Boolean get() = state.overlayStack.lastOrNull() is AuthRoute

/**
* True while the overlay is showing a screen that runs before there is a session, so a locked
* session is what it is there for rather than a reason to gate it. The import flow is not one
* of these: it only ever runs after an unlock, so a session that ends under it does gate it.
*/
private val runsWithoutSession: Boolean
get() = when (state.overlayStack.lastOrNull()) {
is AuthRoute, is OnboardingRoute, is TotpImportRedirect -> true
else -> false
}

/** The one choke point every gated mutator below shares, so none can forget the check. */
private inline fun whenUnlocked(action: () -> Unit) {
if (!isGated) action()
}

fun navigate(route: NavKey) = whenUnlocked {
val isTopLevel = !state.isOverlaid && route in state.backStacks
if (isTopLevel) selectTopLevel(route)
else state.currentStack.add(route)
}
Expand All @@ -26,28 +57,59 @@ class AppNavigator(val state: AppNavigationState) {
else state.topLevelRoute = route
}

/** Replaces the launch flow with [route], so back from it leaves the app. */
fun replaceLaunchFlow(route: NavKey) {
state.launchStack.clear()
state.launchStack.add(route)
/**
* Replaces the overlay with [route], so back from it leaves the app. Deliberately not gated,
* unlike [pushOntoOverlay]: it clears whatever is on top first - including a gate - rather
* than stacking above it, so it also doubles as how a gate is legitimately swapped for another
* (see `AppNavigator.openGateFor`).
*/
fun replaceOverlay(route: NavKey) {
state.overlayStack.clear()
state.overlayStack.add(route)
}

/** Ends the launch flow and hands the window to the app proper. */
fun finishLaunchFlow() {
state.launchStack.clear()
/**
* Clears the overlay and hands the window to the app proper. Refused while the gate is up:
* this is the one path that would drop a gate without anything having authenticated, and the
* screens that call it sit under the gate rather than over it.
*/
fun clearOverlay() = whenUnlocked { state.overlayStack.clear() }

/**
* Adds [route] to the overlay without disturbing whatever is already on it. Gated, unlike
* [replaceOverlay]: stacking on top of an existing gate would show [route] unauthenticated
* above it.
*/
fun pushOntoOverlay(route: NavKey) = whenUnlocked { state.overlayStack.add(route) }

/**
* Hides what is showing behind an unlock gate and blocks back until [unlock]. Nothing
* underneath is disturbed or torn down: a screen holding a secret clears it by observing the
* session, the way ChangePasswordViewModel does. A no-op while a screen that runs without a
* session is already up, which covers both a gate already in place and the caller re-firing.
*/
fun lock() {
if (runsWithoutSession) return
pushOntoOverlay(AuthRoute())
}

/** Lifts the gate. Only gates reach here; first run is taken down with [clearOverlay]. */
fun unlock() {
if (!isGated) return
state.overlayStack.removeLastOrNull()
}

/**
* Shows [detail] in the dashboard's detail pane, replacing any detail already open, so back
* from a detail always lands on the list.
*/
fun showDetail(detail: RouteDestination.Detail) {
fun showDetail(detail: RouteDestination.Detail) = whenUnlocked {
closeDetail()
state.currentStack.add(detail)
}

/** Opens [detail] on top of the detail already showing, so back returns to it. */
fun openOnTopOfDetail(detail: RouteDestination.Detail) {
fun openOnTopOfDetail(detail: RouteDestination.Detail) = whenUnlocked {
state.currentStack.add(detail)
}

Expand All @@ -60,17 +122,23 @@ class AppNavigator(val state: AppNavigationState) {
/**
* Drops a detail the list picked on the user's behalf. A form is left alone: it may hold typing
* that is not saved yet.
*
* Reaches past the overlay to the tab that owns the detail, rather than going through
* [AppNavigationState.currentStack]. The window can narrow while the gate is up - rotating at
* the lock screen is an ordinary thing to do - and the overlay's top entry is never a detail,
* so this would find nothing to drop and the tab would keep a selection the user never made,
* waiting full screen behind the unlock.
*/
fun dropAutoSelectedDetail() {
val stack = state.currentStack
val stack = state.backStacks.getValue(state.topLevelRoute)
if (stack.lastOrNull() is RouteDestination.ViewItem) stack.removeLastOrNull()
}

/**
* Goes back one destination, but never down to nothing. The display stops handling back once a
* stack is one deep, so the app is what closes.
* Goes back one destination, never down to nothing, and never while the gate is up: the
* overlay can be deeper than one entry then, so a depth check alone would pop the gate.
*/
fun goBack() {
fun goBack() = whenUnlocked {
val stack = state.currentStack
if (stack.size > 1) stack.removeLastOrNull()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) ->

assignTotpEntries(
metadata = WindowOwning,
onImportFinished = { navigator.finishLaunchFlow() },
onImportFinished = { navigator.clearOverlay() },
navigateUp = { navigator.goBack() },
)

Expand All @@ -74,7 +74,7 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) ->

onboardingEntries(
metadata = WindowOwning,
onSuccess = { totpUri -> navigator.finishUnlock(totpUri) },
onSuccess = { totpUri -> navigator.finishFirstRun(totpUri) },
)

dashboardEntries(navigator = navigator)
Expand Down Expand Up @@ -132,12 +132,24 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) ->
}
}

/** Replaces the launch flow, so back from the gate leaves the app rather than a consumed link. */
/** Replaces the overlay, so back from the gate leaves the app rather than a consumed link. */
internal fun AppNavigator.openGateFor(hasAccess: Boolean, uri: String) {
replaceLaunchFlow(if (hasAccess) AuthRoute(uri = uri) else OnboardingRoute(uri = uri))
replaceOverlay(if (hasAccess) AuthRoute(uri = uri) else OnboardingRoute(uri = uri))
}

/** Lifts the gate that just authenticated, revealing a picker preserved under it. */
internal fun AppNavigator.finishUnlock(totpUri: String?) {
unlock()
startImport(totpUri)
}

/** Takes first run down. It is on the overlay as the launch route, not as a gate. */
internal fun AppNavigator.finishFirstRun(totpUri: String?) {
clearOverlay()
startImport(totpUri)
}

private fun AppNavigator.finishUnlock(totpUri: String?) {
if (totpUri == null) finishLaunchFlow()
else replaceLaunchFlow(SelectItemForTotpRoute(totpUri))
/** A code this run carried replaces whatever the dismissal revealed. */
private fun AppNavigator.startImport(totpUri: String?) {
if (totpUri != null) pushOntoOverlay(SelectItemForTotpRoute(totpUri))
}
Loading
Loading