diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04c61ee78..c5d49e7fc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -151,12 +151,14 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.androidx.material3.adaptive.navigation) + implementation(libs.androidx.material3.adaptive.layout) implementation(libs.androidx.material3.adaptive.navigation.suite) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.material3.adaptive.navigation3) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) testImplementation(libs.kotlin.test) - testImplementation(libs.androidx.navigation.testing) testImplementation(libs.robolectric) testImplementation(libs.androidx.junit) 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 b7a836456..fab65b7c3 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 @@ -6,18 +6,19 @@ import androidx.compose.material.icons.filled.Cast 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.core.ui.RouteDestination as UiRouteDestination -import de.davis.keygo.feature.settings.presentation.SettingsGraphRoute +import de.davis.keygo.feature.settings.presentation.SettingsRoute +/** The navigation bar's destinations. */ enum class AppDestinations( - val route: UiRouteDestination, + val route: NavKey, @StringRes val label: Int, val icon: ImageVector, @StringRes val contentDescription: Int ) { - HOME(RouteDestination.Home.NavGraph, R.string.home, Icons.Default.Home, R.string.home), + HOME(RouteDestination.Home, R.string.home, Icons.Default.Home, R.string.home), CONNECTIVITY( RouteDestination.Connectivity, R.string.connectivity, @@ -25,9 +26,11 @@ enum class AppDestinations( R.string.connectivity ), SETTINGS( - SettingsGraphRoute, + SettingsRoute, R.string.settings, Icons.Default.Settings, R.string.settings ), -} \ No newline at end of file +} + +val TopLevelRoutes: Set = AppDestinations.entries.mapTo(LinkedHashSet()) { it.route } 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 017eec32a..e7d3ca252 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 @@ -1,73 +1,48 @@ package de.davis.keygo.app.presentation +import android.content.Intent import android.os.Bundle -import android.util.Log -import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.dialog -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navigation -import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries -import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer -import de.davis.keygo.R +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.deeplink.DeepLinkRequest +import androidx.navigation3.scene.DialogSceneStrategy import de.davis.keygo.app.presentation.component.KeyGoNavigationWrapper +import de.davis.keygo.app.presentation.navigation.AppNavigator +import de.davis.keygo.app.presentation.navigation.keyGoEntryProvider +import de.davis.keygo.app.presentation.navigation.rememberAppNavigationState +import de.davis.keygo.app.presentation.navigation.resolveAppShell import de.davis.keygo.core.presentation.model.RouteDestination -import de.davis.keygo.core.ui.model.PendingTotpImport +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.core.ui.navigation.KeyGoNavDisplay import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.domain.snackbar.SnackbarManager import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager import de.davis.keygo.core.util.presentation.snackbar.SnackbarHandler -import de.davis.keygo.dashboard.presentation.DetailType -import de.davis.keygo.dashboard.presentation.dashboardGraph import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.auth.presentation.authGraph -import de.davis.keygo.feature.backup.presentation.BackupHubRoute -import de.davis.keygo.feature.backup.presentation.backupGraph -import de.davis.keygo.feature.item.create.presentation.totp.AssignTotpRoute -import de.davis.keygo.feature.item.create.presentation.totp.assignTotpGraph import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute -import de.davis.keygo.feature.onboarding.presentation.onboardingGraph -import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute -import de.davis.keygo.feature.settings.presentation.settingsGraph -import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute -import de.davis.keygo.feature.totp.presentation.selectItemForTotpGraph -import de.davis.keygo.feature.totp.presentation.totpImportRedirectGraph -import de.davis.keygo.item.dialog.SelectItemContent -import kotlinx.coroutines.launch +import de.davis.keygo.feature.totp.presentation.TotpImportDeepLinkMatcher +import de.davis.keygo.feature.totp.presentation.TotpImportRedirect import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.compose.koinInject -private const val TAG = "MainActivity" - class MainActivity : FragmentActivity() { private val viewModel by viewModel() @@ -83,226 +58,98 @@ class MainActivity : FragmentActivity() { enableEdgeToEdge() setContent { - val hasAccess by viewModel.isReturningUser.collectAsState() - hasAccess ?: return@setContent + // Null until the account has been looked up, which the splash screen waits out. + val hasAccess = viewModel.isReturningUser.collectAsState().value ?: return@setContent KeyGoTheme { val snackbarManager = koinInject() CompositionLocalProvider( LocalSnackbarManager provides snackbarManager, ) { - App(hasAccess = hasAccess == true) + App(hasAccess = hasAccess, launchRoute = launchRoute(hasAccess)) } } } } -} - -private fun destinationAfterUnlock(totpUri: String?): Any = - totpUri?.let { SelectItemForTotpRoute(it) } ?: RouteDestination.TopLevelAppGraph -internal fun NavController.navigateToValidatedImport( - hasAccess: Boolean, - pending: PendingTotpImport -) { - navigate( - if (hasAccess) AuthRoute( - totpInfo = pending.totpInfo, - queries = pending.queries, - ) - else OnboardingRoute( - totpInfo = pending.totpInfo, - queries = pending.queries, - ), - ) { - popUpTo(graph.findStartDestination().id) { inclusive = true } - } + private fun launchRoute(hasAccess: Boolean): NavKey = + intent.totpImportRedirect() ?: if (hasAccess) AuthRoute() else OnboardingRoute() } -/** - * Where the picker's answer goes. The picker stays composed and collecting through its exit - * transition, so a double tap on a row can fire twice before the first navigation leaves it. - * [AssignTotpRoute] is a data class, so launchSingleTop dedupes the repeat instead of pushing it - * twice onto the back stack. - */ -internal fun NavController.navigateToAssignTotp(route: AssignTotpRoute) { - navigate(route) { - launchSingleTop = true - } +private fun Intent.totpImportRedirect(): TotpImportRedirect? { + // A DeepLinkRequest with neither a uri nor extras throws, and the launcher intent has no data. + val uri = data ?: return null + return TotpImportDeepLinkMatcher.match(DeepLinkRequest(uri))?.key } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -private fun App(hasAccess: Boolean) { - val listNavigator = rememberListDetailPaneScaffoldNavigator() - val navController = rememberNavController() - val activity = LocalActivity.current +private fun App(hasAccess: Boolean, launchRoute: NavKey) { + val navigationState = rememberAppNavigationState( + launchRoute = launchRoute, + startRoute = RouteDestination.Home, + topLevelRoutes = TopLevelRoutes, + ) + val navigator = remember(navigationState) { AppNavigator(navigationState) } + + val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + } + val listPaneVisible = directive.maxHorizontalPartitions > 1 - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination + DropAutoSelectedDetailWhenListLeaves(listPaneVisible, navigator) - val showPrimaryActionButton = remember(currentDestination, listNavigator.currentDestination) { - currentDestination - ?.hierarchy - ?.any { it.hasRoute() == true } == true && !listNavigator.canNavigateBack() - } + val entries = navigationState.toDecoratedEntries(keyGoEntryProvider(navigator, hasAccess)) + val shell = entries.resolveAppShell(listPaneVisible) - val showChrome = remember(currentDestination, listNavigator.currentDestination) { - currentDestination - ?.hierarchy - ?.any { it.hasRoute() == true } == true && !listNavigator.canNavigateBack() + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + val sceneStrategies = remember(listDetailStrategy) { + listOf(DialogSceneStrategy(), listDetailStrategy) } val snackbarHostState = remember { SnackbarHostState() } SnackbarHandler(snackbarHostState) - val scope = rememberCoroutineScope() - KeyGoNavigationWrapper( - currentDestination = currentDestination, - navigateToTopLevelDestination = { - navController.navigate(it) { - popUpTo { - saveState = true - } - - launchSingleTop = true - restoreState = true - } - }, - onButtonClicked = { - navController.navigate(RouteDestination.Home.SelectItem) - }, - onItemSelected = { type -> - scope.launch { - listNavigator.navigateTo( - ThreePaneScaffoldRole.Primary, - DetailType.Modify.CreateNew(type) - ) - } - }, - showChrome = showChrome, - showPrimaryActionButton = showPrimaryActionButton, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - } - ) { - NavHost( - navController = navController, - startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), + CompositionLocalProvider(LocalIsInSinglePaneMode provides !listPaneVisible) { + KeyGoNavigationWrapper( + selectedRoute = navigationState.topLevelRoute, + navigateToTopLevelDestination = { navigator.navigate(it) }, + onButtonClicked = { navigator.navigate(RouteDestination.SelectItemType) }, + onItemSelected = { type -> navigator.showDetail(RouteDestination.CreateItem(type)) }, + showChrome = shell.showNavigation, + showPrimaryActionButton = shell.showCreateButton, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, ) { - totpImportRedirectGraph( - onValidated = { pending -> - navController.navigateToValidatedImport( - hasAccess, - pending - ) - }, - // The app was launched only to import this code. With nothing left to import, the - // Activity is what closes, and :app is the only module that owns one. - onRejected = { - activity?.finish() ?: Log.w( - TAG, - "No activity to finish after rejecting an invalid TOTP deep link" - ) - }, - ) - - selectItemForTotpGraph( - onItemSelected = { totpUri, itemId -> - navController.navigateToAssignTotp(AssignTotpRoute(totpUri, itemId.toString())) - }, - onCreateNew = { totpUri -> - navController.navigateToAssignTotp(AssignTotpRoute(totpUri)) - }, - ) - - assignTotpGraph( - onImportFinished = { - navController.navigate(RouteDestination.TopLevelAppGraph) { - popUpTo { inclusive = true } - } - }, - navigateUp = { navController.navigateUp() }, - ) - - authGraph( - onSuccess = { totpUri -> - navController.navigate(destinationAfterUnlock(totpUri)) { - popUpTo { inclusive = true } - } - } - ) - - onboardingGraph( - onSuccess = { totpUri -> - navController.navigate(destinationAfterUnlock(totpUri)) { - popUpTo { inclusive = true } - } - } - ) - - navigation( - startDestination = RouteDestination.Home.NavGraph - ) { - navigation( - startDestination = RouteDestination.Home.Root - ) { - dialog { - SelectItemContent( - onSelect = { - scope.launch { - navController.navigateUp() - scope.launch { - listNavigator.navigateTo( - ThreePaneScaffoldRole.Primary, - DetailType.Modify.CreateNew(it) - ) - } - } - } - ) - } - - dashboardGraph(listNavigator = listNavigator) - } - - settingsGraph( - onOpenChangePassword = { navController.navigate(ChangePasswordRoute) }, - onShowLibraries = { navController.navigate(RouteDestination.Libraries) }, - onOpenBackup = { navController.navigate(BackupHubRoute) }, - onUp = { navController.navigateUp() }, - ) - - composable { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.coming_soon), - style = MaterialTheme.typography.displaySmall - ) - } - } - } - - composable { - Scaffold( - modifier = Modifier.fillMaxSize() - ) { innerPadding -> - val libs by produceLibraries() - LibrariesContainer( - libraries = libs, - modifier = Modifier.fillMaxSize(), - contentPadding = innerPadding - ) - } - } - - backupGraph( - navigateToDestination = navController::navigate, - navigateUp = { navController.navigateUp() }, + KeyGoNavDisplay( + entries = entries, + onBack = { navigator.goBack() }, + sceneStrategies = sceneStrategies, ) } } } + +/** + * Auto-selection is fine beside the list and wrong once the window narrows enough to hand the + * detail the whole screen. Only a change is acted on, so a detail restored after process death + * stays put. + * + * The previous width is saved rather than merely remembered: rotating or folding the device is + * both what this watches for and what recreates the Activity, and a plain `remember` would come + * back seeded with the width it was supposed to compare against, seeing no change at all. + */ +@Composable +private fun DropAutoSelectedDetailWhenListLeaves( + listPaneVisible: Boolean, + navigator: AppNavigator, +) { + var wasListPaneVisible by rememberSaveable { mutableStateOf(listPaneVisible) } + LaunchedEffect(listPaneVisible) { + val listPaneLeft = wasListPaneVisible && !listPaneVisible + wasListPaneVisible = listPaneVisible + if (listPaneLeft) navigator.dropAutoSelectedDetail() + } +} diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/KeyGoNavigationSuiteScaffoldLayout.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/KeyGoNavigationSuiteScaffoldLayout.kt deleted file mode 100644 index f0a2e9505..000000000 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/KeyGoNavigationSuiteScaffoldLayout.kt +++ /dev/null @@ -1,178 +0,0 @@ -package de.davis.keygo.app.presentation.component - -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldState -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldValue -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType -import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastFirst - -@Composable -fun KeyGoNavigationSuiteScaffoldLayout( - navigationSuite: @Composable () -> Unit, - navigationSuiteType: NavigationSuiteType, - state: NavigationSuiteScaffoldState = rememberNavigationSuiteScaffoldState(), - primaryActionContent: @Composable (() -> Unit) = {}, - primaryActionContentHorizontalAlignment: Alignment.Horizontal = - NavigationSuiteScaffoldDefaults.primaryActionContentAlignment, - snackbarHost: @Composable (() -> Unit) = {}, - content: @Composable () -> Unit -) { - val animationProgress by - animateFloatAsState( - targetValue = if (state.currentValue == NavigationSuiteScaffoldValue.Hidden) 0f else 1f, - animationSpec = AnimationSpec - ) - - Layout({ - // Wrap the navigation suite and content composables each in a Box to not propagate the - // parent's (Surface) min constraints to its children (see b/312664933). - Box(Modifier.layoutId(NavigationSuiteLayoutIdTag)) { navigationSuite() } - Box(Modifier.layoutId(PrimaryActionContentLayoutIdTag)) { primaryActionContent() } - Box(Modifier.layoutId(SnackbarHostIdTag)) { snackbarHost() } - Box(Modifier.layoutId(ContentLayoutIdTag)) { content() } - }) { measurables, constraints -> - val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - // Find the navigation suite composable through it's layoutId tag - val navigationPlaceable = - measurables - .fastFirst { it.layoutId == NavigationSuiteLayoutIdTag } - .measure(looseConstraints) - val primaryActionContentPlaceable = - measurables - .fastFirst { it.layoutId == PrimaryActionContentLayoutIdTag } - .measure(looseConstraints) - - val snackbarPlaceable = - measurables - .fastFirst { it.layoutId == SnackbarHostIdTag } - .measure(looseConstraints) - - val isNavigationBar = navigationSuiteType.isNavigationBar - val layoutHeight = constraints.maxHeight - val layoutWidth = constraints.maxWidth - // Find the content composable through it's layoutId tag. - val contentPlaceable = - measurables - .fastFirst { it.layoutId == ContentLayoutIdTag } - .measure( - if (isNavigationBar) { - constraints.copy( - minHeight = - layoutHeight - - (navigationPlaceable.height * animationProgress).toInt(), - maxHeight = - layoutHeight - - (navigationPlaceable.height * animationProgress).toInt() - ) - } else { - constraints.copy( - minWidth = - layoutWidth - - (navigationPlaceable.width * animationProgress).toInt(), - maxWidth = - layoutWidth - - (navigationPlaceable.width * animationProgress).toInt() - ) - } - ) - - - val snackbarHeight = snackbarPlaceable.height - - layout(layoutWidth, layoutHeight) { - if (isNavigationBar) { - // Place content above the navigation component. - contentPlaceable.placeRelative(0, 0) - // Place the navigation component at the bottom of the screen. - navigationPlaceable.placeRelative( - 0, - layoutHeight - (navigationPlaceable.height * animationProgress).toInt() - ) - - // Place the primary action content above the navigation component. - val positionX = - if (primaryActionContentHorizontalAlignment == Alignment.Start) { - PrimaryActionContentPadding.roundToPx() - } else if ( - primaryActionContentHorizontalAlignment == Alignment.CenterHorizontally - ) { - (layoutWidth - primaryActionContentPlaceable.width) / 2 - } else { - layoutWidth - - primaryActionContentPlaceable.width - - PrimaryActionContentPadding.roundToPx() - } - - val fabOffsetFromBottom = primaryActionContentPlaceable.height + - PrimaryActionContentPadding.roundToPx() + - (navigationPlaceable.height * animationProgress).toInt() - - primaryActionContentPlaceable.placeRelative( - positionX, - layoutHeight - fabOffsetFromBottom - ) - - - val snackbarOffsetFromBottom = - if (snackbarHeight != 0) { - snackbarHeight + fabOffsetFromBottom - } else { - 0 - } - snackbarPlaceable.placeRelative( - 0, - layoutHeight - snackbarOffsetFromBottom - ) - } else { - // Place the navigation component at the start of the screen. - navigationPlaceable.placeRelative( - (0 - (navigationPlaceable.width * (1f - animationProgress))).toInt(), - 0 - ) - // Place content to the side of the navigation component. - contentPlaceable.placeRelative( - (navigationPlaceable.width * animationProgress).toInt(), - 0 - ) - - snackbarPlaceable.placeRelative( - (layoutWidth - snackbarPlaceable.width) / 2, - layoutHeight - snackbarPlaceable.height - ) - } - } - } -} - -private val NavigationSuiteType.isNavigationBar - get() = - this == NavigationSuiteType.ShortNavigationBarCompact || - this == NavigationSuiteType.ShortNavigationBarMedium || - this == NavigationSuiteType.NavigationBar - - -private const val SpringDefaultSpatialDamping = 0.9f -private const val SpringDefaultSpatialStiffness = 700.0f -private const val NavigationSuiteLayoutIdTag = "navigationSuite" -private const val PrimaryActionContentLayoutIdTag = "primaryActionContent" -private const val SnackbarHostIdTag = "snackbarHost" -private const val ContentLayoutIdTag = "content" - - -private val PrimaryActionContentPadding = 16.dp - - -private val AnimationSpec: SpringSpec = - spring(dampingRatio = SpringDefaultSpatialDamping, stiffness = SpringDefaultSpatialStiffness) \ No newline at end of file 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 d525ed786..7c4fefb29 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 @@ -1,12 +1,6 @@ package de.davis.keygo.app.presentation.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.shrinkVertically +import android.view.accessibility.AccessibilityManager import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -28,12 +21,11 @@ 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.BottomAppBarDefaults -import androidx.compose.material3.BottomAppBarScrollBehavior import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonMenu @@ -59,17 +51,23 @@ import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.adaptive.currentWindowSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldState +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldValue import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,37 +77,38 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.layout +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.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation3.runtime.NavKey import androidx.window.core.layout.WindowSizeClass import de.davis.keygo.R import de.davis.keygo.app.presentation.AppDestinations import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation -import de.davis.keygo.core.ui.RouteDestination import kotlinx.coroutines.launch -import kotlin.math.roundToInt +import kotlin.math.sign import de.davis.keygo.core.ui.R as CoreUiR @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun KeyGoNavigationWrapper( - currentDestination: NavDestination?, - navigateToTopLevelDestination: (RouteDestination) -> Unit, + selectedRoute: NavKey?, + navigateToTopLevelDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onItemSelected: (VaultItemType) -> Unit, showChrome: Boolean = true, @@ -121,10 +120,8 @@ fun KeyGoNavigationWrapper( snackbarHost: @Composable () -> Unit = {}, content: @Composable () -> Unit, ) { - val adaptiveInfo = currentWindowAdaptiveInfo() - val windowSize = with(LocalDensity.current) { - currentWindowSize().toSize().toDpSize() - } + val adaptiveInfo = currentWindowAdaptiveInfoV2() + val windowSize = LocalWindowInfo.current.containerDpSize val layoutType = when { adaptiveInfo.windowPosture.isTabletop -> NavigationSuiteType.NavigationBar @@ -143,7 +140,40 @@ fun KeyGoNavigationWrapper( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() + val scaffoldState = rememberNavigationSuiteScaffoldState() + + val touchExplorationEnabled = rememberTouchExplorationEnabled() + val hidesOnScroll = + layoutType == NavigationSuiteType.NavigationBar && !touchExplorationEnabled + + var hiddenByScroll by remember { mutableStateOf(false) } + + val density = LocalDensity.current + val scrollConnection = remember(density) { + NavigationScrollConnection( + thresholdPx = with(density) { NavigationScrollThreshold.toPx() }, + onVisibilityChange = { visible -> hiddenByScroll = !visible }, + ) + } + + // A newly selected top level destination shows its own content from the top, and a layout + // type that does not hide leaves nothing to come back from, so both start the component + // visible again. The run behind the flag is cleared with it: left standing at the threshold + // it had reached, the next scroll of a single pixel in the same direction would hide the + // component again without the distance ever being travelled. + LaunchedEffect(selectedRoute, hidesOnScroll) { + hiddenByScroll = false + scrollConnection.reset() + } + + val showNavigation = showChrome && !(hidesOnScroll && hiddenByScroll) + LaunchedEffect(showNavigation) { + if (showNavigation) scaffoldState.show() else scaffoldState.hide() + } + + // Height of the primary action button, so the snackbar can clear it. Measured on the + // button itself and not on its menu, which grows to the full item list when expanded. + var primaryActionHeight by remember { mutableIntStateOf(0) } ModalNavigationDrawer( drawerContent = { @@ -151,7 +181,7 @@ fun KeyGoNavigationWrapper( drawerState = drawerState ) { DrawerContent( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLevelDestination, onButtonClicked = onButtonClicked, onCloseDrawer = { @@ -168,46 +198,42 @@ fun KeyGoNavigationWrapper( drawerState = drawerState ) { Surface(color = containerColor, contentColor = contentColor) { - KeyGoNavigationSuiteScaffoldLayout( + NavigationSuiteScaffoldLayout( navigationSuite = { - Box { - AnimatedVisibility( - visible = showChrome, - enter = when (layoutType) { - NavigationSuiteType.NavigationBar -> expandVertically() - else -> expandHorizontally() - } + fadeIn(), - exit = when (layoutType) { - NavigationSuiteType.NavigationBar -> shrinkVertically() - else -> shrinkHorizontally() - } + fadeOut() - ) { - KeyGoNavigationSuite( - currentDestination = currentDestination, - layoutType = layoutType, - navigateToTopLvlDestination = navigateToTopLevelDestination, - onButtonClicked = onButtonClicked, - onOpenDrawer = { - scope.launch { - drawerState.open() - } - }, - buttonContainerColor = buttonContainerColor, - buttonContentColor = buttonContentColor, - scrollBehavior = scrollBehavior - ) - } - } + KeyGoNavigationSuite( + selectedRoute = selectedRoute, + layoutType = layoutType, + navigateToTopLvlDestination = navigateToTopLevelDestination, + onButtonClicked = onButtonClicked, + onOpenDrawer = { + scope.launch { + drawerState.open() + } + }, + buttonContainerColor = buttonContainerColor, + buttonContentColor = buttonContentColor, + ) }, navigationSuiteType = layoutType, + state = scaffoldState, primaryActionContent = { var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } + val showPrimaryAction = showChrome && showPrimaryActionButton + + // The open menu draws no scrim and consumes nothing outside its items, so the + // destination underneath keeps taking taps and can navigate away while the + // menu is still open. The menu belongs to the shell and outlives that + // navigation, so a destination that drops the button takes the menu with it. + LaunchedEffect(showPrimaryAction) { + if (!showPrimaryAction) fabMenuExpanded = false + } + FloatingActionButtonMenu( expanded = fabMenuExpanded, modifier = Modifier.animateFloatingActionButton( - visible = (showChrome && showPrimaryActionButton) || fabMenuExpanded, + visible = showPrimaryAction || fabMenuExpanded, alignment = Alignment.BottomEnd, ), button = { @@ -227,6 +253,7 @@ fun KeyGoNavigationWrapper( checked = fabMenuExpanded, onCheckedChange = { fabMenuExpanded = !fabMenuExpanded }, modifier = Modifier + .onSizeChanged { primaryActionHeight = it.height } .semantics { traversalIndex = -1f } @@ -256,35 +283,41 @@ fun KeyGoNavigationWrapper( icon = { Icon( imageVector = icon, - contentDescription = null + contentDescription = null, ) }, - text = { Text(text = text) } + text = { Text(text = text) }, ) } } }, - snackbarHost = snackbarHost, content = { Box( Modifier - .consumeWindowInsets( - when (layoutType) { - NavigationSuiteType.NavigationBar -> - NavigationBarDefaults.windowInsets.only(WindowInsetsSides.Bottom) - - NavigationSuiteType.NavigationRail -> - NavigationRailDefaults.windowInsets.only(WindowInsetsSides.Start) - - NavigationSuiteType.NavigationDrawer -> - DrawerDefaults.windowInsets.only(WindowInsetsSides.Start) - - else -> WindowInsets(0, 0, 0, 0) - } + .fillMaxSize() + .consumeWindowInsets(navigationInsets(layoutType, scaffoldState)) + .then( + if (hidesOnScroll) Modifier.nestedScroll(scrollConnection) + else Modifier ) - .nestedScroll(scrollBehavior.nestedScrollConnection) ) { content() + + // This slot ends where the navigation component starts, so a bottom + // aligned host clears the component on its own and follows it as it + // collapses. Only the primary action button is left to pad around. + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding( + bottom = if (showChrome && showPrimaryActionButton) + with(LocalDensity.current) { primaryActionHeight.toDp() } + + PrimaryActionContentPadding + else 0.dp + ) + ) { + snackbarHost() + } } } ) @@ -292,30 +325,27 @@ fun KeyGoNavigationWrapper( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun KeyGoNavigationSuite( - currentDestination: NavDestination?, + selectedRoute: NavKey?, layoutType: NavigationSuiteType, - navigateToTopLvlDestination: (RouteDestination) -> Unit, + navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor), - scrollBehavior: BottomAppBarScrollBehavior? = null, ) { when (layoutType) { NavigationSuiteType.NavigationBar -> { KeyGoNavigationBar( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, - scrollBehavior = scrollBehavior ) } NavigationSuiteType.NavigationRail -> { KeyGoNavigationRail( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, onOpenDrawer = onOpenDrawer, @@ -326,7 +356,7 @@ fun KeyGoNavigationSuite( NavigationSuiteType.NavigationDrawer -> { KeyGoNavigationDrawer( - currentDestination = currentDestination, + selectedRoute = selectedRoute, onButtonClicked = onButtonClicked, navigateToTopLvlDestination = navigateToTopLvlDestination, buttonContainerColor = buttonContainerColor, @@ -338,29 +368,15 @@ fun KeyGoNavigationSuite( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun KeyGoNavigationBar( - currentDestination: NavDestination?, - navigateToTopLvlDestination: (RouteDestination) -> Unit, - scrollBehavior: BottomAppBarScrollBehavior? = null + selectedRoute: NavKey?, + navigateToTopLvlDestination: (NavKey) -> Unit, ) { - NavigationBar( - modifier = Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - // Sets the app bar's height offset to collapse the entire bar's height when - // content is scrolled. - scrollBehavior?.state?.heightOffsetLimit = -placeable.height.toFloat() - - val height = (placeable.height + (scrollBehavior?.state?.heightOffset ?: 0f)) - .coerceAtLeast(0f) - layout(placeable.width, height.roundToInt()) { placeable.place(0, 0) } - } // TODO decide to add appBarDragModifier - ) { + NavigationBar { AppDestinations.entries.forEach { destination -> NavigationBarItem( - selected = currentDestination?.hierarchy?.any { it.hasRoute(destination.route.graphDest) } == true, + selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, icon = { Icon( @@ -377,8 +393,8 @@ fun KeyGoNavigationBar( @Composable fun KeyGoNavigationRail( - currentDestination: NavDestination?, - navigateToTopLvlDestination: (RouteDestination) -> Unit, + selectedRoute: NavKey?, + navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onOpenDrawer: () -> Unit, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, @@ -417,7 +433,7 @@ fun KeyGoNavigationRail( ) { AppDestinations.entries.forEach { destination -> NavigationRailItem( - selected = currentDestination?.hierarchy?.any { it.hasRoute(destination.route.graphDest) } == true, + selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, icon = { Icon( @@ -435,8 +451,8 @@ fun KeyGoNavigationRail( @Composable fun KeyGoNavigationDrawer( - currentDestination: NavDestination?, - navigateToTopLvlDestination: (RouteDestination) -> Unit, + selectedRoute: NavKey?, + navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor) @@ -445,7 +461,7 @@ fun KeyGoNavigationDrawer( modifier = Modifier.widthIn(min = 200.dp, max = 300.dp) ) { DrawerContent( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, buttonContainerColor = buttonContainerColor, @@ -456,8 +472,8 @@ fun KeyGoNavigationDrawer( @Composable fun DrawerContent( - currentDestination: NavDestination?, - navigateToTopLvlDestination: (RouteDestination) -> Unit, + selectedRoute: NavKey?, + navigateToTopLvlDestination: (NavKey) -> Unit, onButtonClicked: () -> Unit, onCloseDrawer: (() -> Unit)? = null, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, @@ -492,32 +508,25 @@ fun DrawerContent( } } - // TODO decide if this or the default ExtendedFab is better (ExtendedFabTextPadding) - FloatingActionButton( + ExtendedFloatingActionButton( onClick = onButtonClicked, modifier = Modifier.padding(top = 8.dp, bottom = 40.dp), containerColor = buttonContainerColor, contentColor = buttonContentColor, - ) { - Row( - modifier = - Modifier - .sizeIn(minWidth = 80.dp /*ExtendedFabMinimumWidth*/) - .padding(horizontal = 16.dp /*ExtendedFabTextPadding - 4.dp*/), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_element_content_description), - ) + text = { Text( text = stringResource(CoreUiR.string.add), modifier = Modifier.weight(1f), - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + ) + }, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_element_content_description), ) } - } + ) Column( modifier = Modifier @@ -535,7 +544,7 @@ fun DrawerContent( label = { Text(text = stringResource(destination.label)) }, - selected = currentDestination?.hierarchy?.any { it.hasRoute(destination.route.graphDest) } == true, + selected = destination.route == selectedRoute, onClick = { navigateToTopLvlDestination(destination.route) }, ) } @@ -543,10 +552,104 @@ fun DrawerContent( } } -fun NavDestination?.hasRoute(dest: RouteDestination): Boolean { - return this?.hasRoute(dest::class) == true +@Composable +private fun navigationInsets( + layoutType: NavigationSuiteType, + state: NavigationSuiteScaffoldState, +): WindowInsets = + if (state.currentValue == NavigationSuiteScaffoldValue.Hidden && !state.isAnimating) + WindowInsets(0, 0, 0, 0) + else when (layoutType) { + NavigationSuiteType.NavigationBar -> + NavigationBarDefaults.windowInsets.only(WindowInsetsSides.Bottom) + + NavigationSuiteType.NavigationRail -> + NavigationRailDefaults.windowInsets.only(WindowInsetsSides.Start) + + NavigationSuiteType.NavigationDrawer -> + DrawerDefaults.windowInsets.only(WindowInsetsSides.Start) + + else -> WindowInsets(0, 0, 0, 0) + } + +/** + * Hides the navigation component once the content has been scrolled [thresholdPx] down, and brings + * it back on the same distance scrolled up. + * + * Only the distance the content actually consumed counts, so overscrolling at either end of a list + * does not move the component, and content that cannot scroll at all never hides it. + */ +private class NavigationScrollConnection( + private val thresholdPx: Float, + private val onVisibilityChange: (visible: Boolean) -> Unit, +) : NestedScrollConnection { + + private var accumulated = 0f + + /** Starts a new run, so the next scroll has to travel the whole threshold to decide again. */ + fun reset() { + accumulated = 0f + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + // A scroll that moved the content nowhere, a horizontal one included, leaves the run + // it interrupted intact. + val delta = consumed.y + if (delta != 0f) { + // A change of direction starts a new run, so scrolling back reverses the decision + // after one threshold instead of first having to undo the whole distance travelled. + if (delta.sign != accumulated.sign) accumulated = 0f + accumulated = (accumulated + delta).coerceIn(-thresholdPx, thresholdPx) + + if (accumulated <= -thresholdPx) onVisibilityChange(false) + else if (accumulated >= thresholdPx) onVisibilityChange(true) + } + + // Nothing is consumed here: the scroll belongs to the content, this only watches it. + return super.onPostScroll(consumed, available, source) + } } +/** + * Whether an accessibility service that uses touch exploration, such as TalkBack, is running. + * + * Scroll driven hiding stays off while one is, the way Material does it for its own app bars: the + * component a screen reader user navigates with must not move out from under them. + */ +@Composable +private fun rememberTouchExplorationEnabled(): Boolean { + val context = LocalContext.current + val accessibilityManager = + remember(context) { context.getSystemService(AccessibilityManager::class.java) } + + var enabled by remember(accessibilityManager) { + mutableStateOf(accessibilityManager?.isTouchExplorationEnabled == true) + } + + DisposableEffect(accessibilityManager) { + if (accessibilityManager == null) return@DisposableEffect onDispose {} + + // The service may have been switched while this was not listening. + enabled = accessibilityManager.isTouchExplorationEnabled + + val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled = it } + accessibilityManager.addTouchExplorationStateChangeListener(listener) + onDispose { accessibilityManager.removeTouchExplorationStateChangeListener(listener) } + } + + return enabled +} + +/** The padding [NavigationSuiteScaffoldLayout] places around the primary action content. */ +private val PrimaryActionContentPadding = 16.dp + +/** How far the content has to be scrolled before the navigation component follows it away. */ +private val NavigationScrollThreshold = 24.dp + @Suppress("VisualLintOverlap") @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Preview(name = "Default") @@ -557,7 +660,7 @@ private fun KeyGoNavigationWrapperPreview() { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { KeyGoNavigationWrapper( - currentDestination = null, + selectedRoute = AppDestinations.entries.first().route, navigateToTopLevelDestination = {}, onButtonClicked = {}, onItemSelected = {}, diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigationState.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigationState.kt new file mode 100644 index 000000000..5bea0a802 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigationState.kt @@ -0,0 +1,114 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer +import de.davis.keygo.core.presentation.model.RouteDestination +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 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. + */ +@Composable +fun rememberAppNavigationState( + launchRoute: NavKey, + startRoute: NavKey, + topLevelRoutes: Set, +): AppNavigationState { + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()), + ) { + mutableStateOf(startRoute) + } + + val launchStack = rememberNavBackStack(launchRoute) + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + AppNavigationState( + launchStack = launchStack, + topLevelRoute = topLevelRoute, + backStacks = backStacks, + ) + } +} + +/** + * 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. + * - 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, + topLevelRoute: MutableState, + val backStacks: Map>, +) { + + /** The selected navigation bar destination. */ + var topLevelRoute: NavKey by topLevelRoute + + /** Whether the launch flow still owns the window. */ + val isLaunching: Boolean get() = launchStack.isNotEmpty() + + /** The stack destinations are currently pushed onto and popped from. */ + val currentStack: NavBackStack + get() = if (isLaunching) launchStack 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. + */ + val openDetail: RouteDestination.Detail? + get() = currentStack.filterIsInstance().lastOrNull() + + /** + * Turns the state into the entries the display renders. Every stack keeps its own decorators, + * so a route that is off screen still holds its saved state and view models. + */ + @Composable + fun toDecoratedEntries( + entryProvider: (NavKey) -> NavEntry, + ): List> { + val launchEntries = rememberDecoratedEntries(launchStack, entryProvider) + val topLevelEntries = backStacks.mapValues { (_, stack) -> + rememberDecoratedEntries(stack, entryProvider) + } + + return if (isLaunching) launchEntries + else topLevelEntries.getValue(topLevelRoute) + } +} + +@Composable +private fun rememberDecoratedEntries( + backStack: NavBackStack, + entryProvider: (NavKey) -> NavEntry, +): List> = rememberDecoratedNavEntries( + backStack = backStack, + entryDecorators = rememberNavEntryDecorators(), + entryProvider = entryProvider, +) 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 new file mode 100644 index 000000000..e9186609b --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt @@ -0,0 +1,81 @@ +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 + +/** + * 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. + */ +class AppNavigator(val state: AppNavigationState) { + + fun navigate(route: NavKey) { + val isTopLevel = !state.isLaunching && route in state.backStacks + if (isTopLevel) selectTopLevel(route) + else state.currentStack.add(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, + * so back from there closes the app. + */ + private fun selectTopLevel(route: NavKey) { + if (route == state.topLevelRoute) state.backStacks.getValue(route).popToBase() + 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) + } + + /** Ends the launch flow and hands the window to the app proper. */ + fun finishLaunchFlow() { + state.launchStack.clear() + } + + /** + * 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) { + closeDetail() + state.currentStack.add(detail) + } + + /** Opens [detail] on top of the detail already showing, so back returns to it. */ + fun openOnTopOfDetail(detail: RouteDestination.Detail) { + state.currentStack.add(detail) + } + + /** Closes whatever detail is open, leaving the list. */ + fun closeDetail() { + val stack = state.currentStack + while (stack.lastOrNull() is RouteDestination.Detail) stack.removeLastOrNull() + } + + /** + * 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. + */ + fun dropAutoSelectedDetail() { + val stack = state.currentStack + 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. + */ + fun goBack() { + val stack = state.currentStack + if (stack.size > 1) stack.removeLastOrNull() + } +} + +private fun NavBackStack.popToBase() { + while (size > 1) removeLastOrNull() +} diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppShell.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppShell.kt new file mode 100644 index 000000000..11a4b77da --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppShell.kt @@ -0,0 +1,74 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata + +enum class ShellVisibility { + Always, + Never, + BesideListPane, +} + +/** + * The components drawn around a destination: the navigation bar, rail or drawer, and the create + * button that starts a new item. + */ +data class AppShell( + val navigation: ShellVisibility, + val createButton: ShellVisibility, +) + +data class ResolvedAppShell( + val showNavigation: Boolean, + val showCreateButton: Boolean, +) + +/** + * Metadata declaring the shell a destination wants. + * + * @param createButton defaults to following [navigation] + */ +fun appShell( + navigation: ShellVisibility, + createButton: ShellVisibility = navigation, +): Map = metadata { put(AppShellKey, AppShell(navigation, createButton)) } + +/** + * The shell the topmost entry asks for. + * + * @param listPaneVisible whether the window is wide enough to show the list beside a detail, taken + * from the same scaffold directive the list-detail scene lays itself out with + */ +fun List>.resolveAppShell(listPaneVisible: Boolean): ResolvedAppShell { + val requested = lastOrNull()?.metadata?.get(AppShellKey) ?: WindowOwningShell + return ResolvedAppShell( + showNavigation = requested.navigation.isVisible(listPaneVisible), + showCreateButton = requested.createButton.isVisible(listPaneVisible), + ) +} + +private val WindowOwningShell = AppShell(ShellVisibility.Never, ShellVisibility.Never) + +private object AppShellKey : NavMetadataKey { + override fun toString(): String = "de.davis.keygo.app.shell" +} + +private fun ShellVisibility.isVisible(listPaneVisible: Boolean): Boolean = when (this) { + ShellVisibility.Always -> true + ShellVisibility.Never -> false + ShellVisibility.BesideListPane -> listPaneVisible +} + +/** + * The destination owns the whole window: no navigation, no create button. Also the fallback for a + * destination that declares nothing, so a new screen shows up bare rather than borrowing chrome. + */ +val WindowOwning: Map = metadata { put(AppShellKey, WindowOwningShell) } + +val NavigationOnly: Map = appShell( + navigation = ShellVisibility.Always, + createButton = ShellVisibility.Never, +) 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 new file mode 100644 index 000000000..64fa44276 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -0,0 +1,143 @@ +package de.davis.keygo.app.presentation.navigation + +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.DialogSceneStrategy +import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import de.davis.keygo.R +import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.dashboard.presentation.dashboardEntries +import de.davis.keygo.feature.auth.presentation.AuthRoute +import de.davis.keygo.feature.auth.presentation.authEntries +import de.davis.keygo.feature.backup.presentation.BackupHubRoute +import de.davis.keygo.feature.backup.presentation.backupEntries +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.settings.presentation.ChangePasswordRoute +import de.davis.keygo.feature.settings.presentation.settingsEntries +import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute +import de.davis.keygo.feature.totp.presentation.selectItemForTotpEntries +import de.davis.keygo.feature.totp.presentation.totpImportRedirectEntries +import de.davis.keygo.item.dialog.SelectItemContent + +private const val TAG = "KeyGoEntryProvider" + +@Composable +fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> NavEntry { + val activity = LocalActivity.current + + return entryProvider { + totpImportRedirectEntries( + metadata = WindowOwning, + onValidated = { uri -> navigator.openGateFor(hasAccess, uri) }, + // The app was launched only to import this code, so the Activity is what closes. + onRejected = { + if (activity != null) activity.finish() + else Log.w(TAG, "No activity to finish after rejecting an invalid TOTP deep link") + }, + ) + + selectItemForTotpEntries( + metadata = WindowOwning, + onItemSelected = { totpUri, itemId -> + navigator.navigate(AssignTotpRoute(totpUri, itemId.toString())) + }, + onCreateNew = { totpUri -> navigator.navigate(AssignTotpRoute(totpUri)) }, + ) + + assignTotpEntries( + metadata = WindowOwning, + onImportFinished = { navigator.finishLaunchFlow() }, + navigateUp = { navigator.goBack() }, + ) + + authEntries( + metadata = WindowOwning, + onSuccess = { totpUri -> navigator.finishUnlock(totpUri) }, + ) + + onboardingEntries( + metadata = WindowOwning, + onSuccess = { totpUri -> navigator.finishUnlock(totpUri) }, + ) + + dashboardEntries(navigator = navigator) + + entry( + // The sheet sits over the dashboard, which keeps its shell while the sheet is open. + metadata = DialogSceneStrategy.dialog() + appShell(ShellVisibility.Always), + ) { + SelectItemContent( + onSelect = { type -> + navigator.goBack() + navigator.showDetail(RouteDestination.CreateItem(type)) + }, + ) + } + + settingsEntries( + metadata = NavigationOnly, + onOpenChangePassword = { navigator.navigate(ChangePasswordRoute) }, + onShowLibraries = { navigator.navigate(RouteDestination.Libraries) }, + onOpenBackup = { navigator.navigate(BackupHubRoute) }, + onUp = { navigator.goBack() }, + ) + + backupEntries( + metadata = WindowOwning, + navigateToDestination = { navigator.navigate(it) }, + navigateUp = { navigator.goBack() }, + ) + + entry(metadata = NavigationOnly) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(id = R.string.coming_soon), + style = MaterialTheme.typography.displaySmall, + ) + } + } + + entry(metadata = WindowOwning) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + val libs by produceLibraries() + LibrariesContainer( + libraries = libs, + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding, + ) + } + } + } +} + +/** Replaces the launch flow, 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)) +} + +private fun AppNavigator.finishUnlock(totpUri: String?) { + if (totpUri == null) finishLaunchFlow() + else replaceLaunchFlow(SelectItemForTotpRoute(totpUri)) +} diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt index 21399bbc3..24c9583b0 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt @@ -1,40 +1,51 @@ package de.davis.keygo.core.presentation.model -import de.davis.keygo.core.ui.RouteDestination as UiRouteDestination +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType import kotlinx.serialization.Serializable +import java.util.UUID -sealed interface RouteDestination : UiRouteDestination { - - override val graphDest: RouteDestination - get() = this +sealed interface RouteDestination : NavKey { @Serializable - data object TopLevelAppGraph : RouteDestination + data object Home : RouteDestination - sealed interface Home : RouteDestination { + /** A destination that fills the dashboard's detail pane, or the window once there is one. */ + sealed interface Detail : RouteDestination - override val graphDest: RouteDestination - get() = NavGraph + @Serializable + data class ViewItem(val itemId: String) : Detail { - @Serializable - data object NavGraph : Home + constructor(itemId: ItemId) : this(itemId.toString()) - @Serializable - data object Root : Home + val id: ItemId get() = UUID.fromString(itemId) + } - @Serializable - data object SelectItem : Home + sealed interface Form : Detail { + val itemType: VaultItemType } @Serializable - data object Connectivity : RouteDestination { - override val graphDest: RouteDestination - get() = Connectivity - } + data class CreateItem(override val itemType: VaultItemType) : Form @Serializable - data object Libraries : RouteDestination { - override val graphDest: RouteDestination - get() = Libraries + data class EditItem( + override val itemType: VaultItemType, + val itemId: String, + ) : Form { + + constructor(itemType: VaultItemType, itemId: ItemId) : this(itemType, itemId.toString()) + + val id: ItemId get() = UUID.fromString(itemId) } -} \ No newline at end of file + + @Serializable + data object SelectItemType : RouteDestination + + @Serializable + data object Connectivity : RouteDestination + + @Serializable + data object Libraries : RouteDestination +} diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt new file mode 100644 index 000000000..cbb6df8f6 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt @@ -0,0 +1,85 @@ +package de.davis.keygo.dashboard.presentation + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.app.presentation.navigation.AppNavigator +import de.davis.keygo.app.presentation.navigation.ShellVisibility +import de.davis.keygo.app.presentation.navigation.appShell +import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.core.presentation.model.NavigationEvent +import de.davis.keygo.feature.item.create.presentation.EditVaultItemScreen +import de.davis.keygo.feature.item.view.ViewVaultItemScreen +import de.davis.keygo.feature.list_screen.presentation.ItemListScreen + +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) +fun EntryProviderScope.dashboardEntries(navigator: AppNavigator) { + entry( + metadata = ListDetailSceneStrategy.listPane() + appShell(ShellVisibility.Always), + ) { + val listPaneVisible = !LocalIsInSinglePaneMode.current + val openDetail = navigator.state.openDetail + + ItemListScreen( + onItemClick = { itemId -> navigator.showDetail(RouteDestination.ViewItem(itemId)) }, + onCreateItemRequest = { type -> + navigator.showDetail(RouteDestination.CreateItem(type)) + }, + onItemsDelete = { deleted, firstItemId -> + val shown = navigator.state.openDetail as? RouteDestination.ViewItem + if (shown != null && shown.id in deleted) { + // Beside the list the next item takes the pane; on its own it would show + // nothing. + if (listPaneVisible && firstItemId != null) + navigator.showDetail(RouteDestination.ViewItem(firstItemId)) + else navigator.closeDetail() + } + }, + openItemId = (openDetail as? RouteDestination.ViewItem)?.id, + // Never picks a row over a form the user may still be filling in. + autoSelectFirst = listPaneVisible && openDetail !is RouteDestination.Form, + dockedSearchResults = listPaneVisible, + enableDeletion = true, + enableSelection = true, + ) + } + + entry(metadata = DetailPaneMetadata) { route -> + ViewVaultItemScreen( + itemId = route.id, + navigate = { event -> + when (event) { + NavigationEvent.NavigateBack -> navigator.goBack() + + is NavigationEvent.NavigateToEdit -> navigator.openOnTopOfDetail( + RouteDestination.EditItem(event.vaultType, event.itemId), + ) + } + }, + ) + } + + entry(metadata = DetailPaneMetadata) { route -> + EditVaultItemScreen( + detailPaneInformation = DetailPaneInformation.Init.New(route.itemType), + onCreated = { navigator.goBack() }, + navigateBack = { navigator.goBack() }, + ) + } + + entry(metadata = DetailPaneMetadata) { route -> + EditVaultItemScreen( + detailPaneInformation = DetailPaneInformation.Init.Existing(route.itemType, route.id), + onCreated = { navigator.goBack() }, + navigateBack = { navigator.goBack() }, + ) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private val DetailPaneMetadata: Map = + ListDetailSceneStrategy.detailPane() + appShell(ShellVisibility.BesideListPane) diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt deleted file mode 100644 index 5e0f4ca57..000000000 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt +++ /dev/null @@ -1,180 +0,0 @@ -package de.davis.keygo.dashboard.presentation - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import de.davis.keygo.core.presentation.model.RouteDestination -import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode -import de.davis.keygo.feature.item.core.presentation.model.NavigationEvent -import de.davis.keygo.feature.item.create.presentation.EditVaultItemScreen -import de.davis.keygo.feature.item.view.ViewVaultItemScreen -import de.davis.keygo.feature.list_screen.presentation.ItemListScreen -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) -fun NavGraphBuilder.dashboardGraph( - listNavigator: ThreePaneScaffoldNavigator, -) { - composable { - val isSinglePaneMode by remember(listNavigator.scaffoldDirective) { - derivedStateOf { - listNavigator.scaffoldDirective.maxHorizontalPartitions == 1 - } - } - val scope = rememberCoroutineScope() - - LaunchedEffect(isSinglePaneMode) { - if (isSinglePaneMode && listNavigator.canNavigateBack(BackNavigationBehavior.PopUntilCurrentDestinationChange)) { - listNavigator.navigateBack(BackNavigationBehavior.PopUntilCurrentDestinationChange) - } - } - - val openedItemId by remember(listNavigator) { - derivedStateOf { - (listNavigator.currentDestination?.contentKey as? DetailType.View)?.itemId - } - } - - val isModifyScreenActive by remember(listNavigator) { - derivedStateOf { - listNavigator.currentDestination?.contentKey is DetailType.Modify - } - } - - CompositionLocalProvider( - LocalIsInSinglePaneMode provides isSinglePaneMode, - ) { - NavigableListDetailPaneScaffold( - navigator = listNavigator, - defaultBackBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange, - listPane = { - AnimatedPane { - ItemListScreen( - onItemClick = { id -> - scope.launch { - listNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DetailType.View(id) - ) - } - }, - onItemsDelete = { deleted, firstItemId -> - if (openedItemId in deleted) { - scope.launch { - if (!isSinglePaneMode && !isModifyScreenActive) - firstItemId?.let { - listNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DetailType.View(firstItemId) - ) - } - // Navigate back if there is no item, so the deleted item's content is not being shown in the detail pane - ?: listNavigator.navigateBack(BackNavigationBehavior.PopUntilCurrentDestinationChange) - } - } - }, - onCreateItemRequest = { - scope.launch { - listNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DetailType.Modify.CreateNew(it) - ) - } - }, - autoSelectFirst = !isSinglePaneMode && !isModifyScreenActive, - dockedSearchResults = !LocalIsInSinglePaneMode.current, - enableDeletion = true, - enableSelection = true, - ) - } - }, - detailPane = { - AnimatedPane { - when (val detailItem = listNavigator.currentDestination?.contentKey) { - is DetailType.View -> { - ViewVaultItemScreen( - itemId = detailItem.itemId, - navigate = { event -> - when (event) { - NavigationEvent.NavigateBack -> scope.launch { - listNavigator.navigateBack() - } - - is NavigationEvent.NavigateToEdit -> scope.launch { - listNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DetailType.Modify.Edit( - event.vaultType, - event.itemId - ) - ) - } - } - } - ) - } - - is DetailType.Modify -> { - val store = remember { ViewModelStore() } - - DisposableEffect(detailItem) { - onDispose { - store.clear() - } - } - - val storeOwner = remember(store) { - object : ViewModelStoreOwner { - override val viewModelStore: ViewModelStore = store - } - } - - CompositionLocalProvider( - LocalViewModelStoreOwner provides storeOwner - ) { - EditVaultItemScreen( - detailPaneInformation = detailItem.asDetailPaneInformation(), - onCreated = { - scope.launch { - // We don't want to pop the detail pane entirely, - // Just until the content changes - listNavigator.navigateBack( - BackNavigationBehavior.PopUntilContentChange - ) - } - }, - navigateBack = { - scope.launch { - listNavigator.navigateBack( - BackNavigationBehavior.PopUntilContentChange - ) - } - } - ) - } - } - - else -> {} - } - } - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt deleted file mode 100644 index 09a058634..000000000 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt +++ /dev/null @@ -1,26 +0,0 @@ -package de.davis.keygo.dashboard.presentation - -import android.os.Parcelable -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed interface DetailType : Parcelable { - - @Parcelize - sealed interface Modify : DetailType { - val vaultItemType: VaultItemType - - data class CreateNew(override val vaultItemType: VaultItemType) : Modify - data class Edit(override val vaultItemType: VaultItemType, val itemId: ItemId) : Modify - } - - data class View(val itemId: ItemId) : DetailType -} - -fun DetailType.Modify.asDetailPaneInformation() = when (this) { - is DetailType.Modify.CreateNew -> DetailPaneInformation.Init.New(vaultItemType) - is DetailType.Modify.Edit -> DetailPaneInformation.Init.Existing(vaultItemType, itemId) -} \ No newline at end of file diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt deleted file mode 100644 index ec7188cb1..000000000 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt +++ /dev/null @@ -1,267 +0,0 @@ -package de.davis.keygo.app.presentation - -import androidx.core.net.toUri -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.compose.ComposeNavigator -import androidx.navigation.compose.DialogNavigator -import androidx.navigation.createGraph -import androidx.navigation.testing.TestNavHostController -import androidx.navigation.toRoute -import androidx.test.core.app.ApplicationProvider -import de.davis.keygo.core.item.domain.alias.newItemId -import de.davis.keygo.core.ui.model.PendingTotpImport -import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.auth.presentation.authGraph -import de.davis.keygo.feature.item.create.presentation.totp.AssignTotpRoute -import de.davis.keygo.feature.item.create.presentation.totp.assignTotpGraph -import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute -import de.davis.keygo.feature.onboarding.presentation.onboardingGraph -import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute -import de.davis.keygo.feature.totp.presentation.TotpImportRedirect -import de.davis.keygo.feature.totp.presentation.selectItemForTotpGraph -import de.davis.keygo.feature.totp.presentation.totpImportRedirectGraph -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class TotpImportNavGraphTest { - - private fun navController(hasAccess: Boolean): TestNavHostController { - val controller = - TestNavHostController(ApplicationProvider.getApplicationContext()) - controller.navigatorProvider.addNavigator(ComposeNavigator()) - controller.navigatorProvider.addNavigator(DialogNavigator()) - - controller.graph = controller.createGraph( - startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), - ) { - totpImportRedirectGraph(onValidated = {}, onRejected = {}) - selectItemForTotpGraph(onItemSelected = { _, _ -> }, onCreateNew = {}) - assignTotpGraph(onImportFinished = {}, navigateUp = {}) - authGraph(onSuccess = {}) - onboardingGraph(onSuccess = {}) - } - - return controller - } - - @Test - fun `graph builds for an account that already has access`() { - val controller = navController(hasAccess = true) - - assertTrue(controller.currentDestination?.hasRoute() == true) - } - - @Test - fun `graph builds for an account without access`() { - val controller = navController(hasAccess = false) - - assertTrue(controller.currentDestination?.hasRoute() == true) - } - - @Test - fun `otpauth deep link resolves to the redirect destination`() { - val controller = navController(hasAccess = true) - - controller.navigate("otpauth://totp/Example:me@example.com?secret=ABC".toUri()) - - val entry = assertNotNull(controller.currentBackStackEntry) - assertTrue(entry.destination.hasRoute()) - - val route = entry.toRoute() - assertEquals("Example:me@example.com", route.totpInfo) - assertEquals("secret=ABC", route.queries) - assertEquals( - "otpauth://totp/Example:me@example.com?secret=ABC", - route.pendingImport.uri, - ) - } - - @Test - fun `AuthRoute round trips the pending import through the back stack`() { - val controller = navController(hasAccess = true) - val redirect = TotpImportRedirect( - totpInfo = "Example:me@example.com", - queries = "secret=ABC", - ) - - controller.navigate( - AuthRoute(totpInfo = redirect.totpInfo, queries = redirect.queries), - ) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals(redirect.pendingImport, route.pendingTotpImport) - assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", route.uri) - } - - @Test - fun `OnboardingRoute round trips the pending import through the back stack`() { - val controller = navController(hasAccess = false) - val redirect = TotpImportRedirect( - totpInfo = "Example:me@example.com", - queries = "secret=ABC", - ) - - controller.navigate( - OnboardingRoute(totpInfo = redirect.totpInfo, queries = redirect.queries), - ) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals(redirect.pendingImport, route.pendingTotpImport) - assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", route.uri) - } - - @Test - fun `a plain launch carries no pending import`() { - val controller = navController(hasAccess = true) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals(PendingTotpImport(), route.pendingTotpImport) - assertNull(route.uri) - } - - @Test - fun `the picker route carries the whole uri`() { - val controller = navController(hasAccess = true) - - controller.navigate(SelectItemForTotpRoute(DEEP_LINK_URI)) - - val entry = assertNotNull(controller.currentBackStackEntry) - assertTrue(entry.destination.hasRoute()) - assertEquals(DEEP_LINK_URI, entry.toRoute().totpUri) - } - - @Test - fun `choosing an item carries its id to the form`() { - val controller = navController(hasAccess = true) - val itemId = newItemId() - - controller.navigate(AssignTotpRoute(DEEP_LINK_URI, itemId.toString())) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals(DEEP_LINK_URI, route.totpUri) - assertEquals(itemId, route.selectedItemId) - } - - @Test - fun `creating a new item carries no id`() { - val controller = navController(hasAccess = true) - - controller.navigate(AssignTotpRoute(DEEP_LINK_URI)) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals(DEEP_LINK_URI, route.totpUri) - assertNull(route.selectedItemId) - } - - @Test - fun `the picker replaces the auth entry so back leaves the app`() { - val controller = navController(hasAccess = true) - - controller.navigate(SelectItemForTotpRoute(DEEP_LINK_URI)) { - popUpTo { inclusive = true } - } - - assertTrue(controller.currentDestination?.hasRoute() == true) - assertFalse( - controller.currentBackStack.value.any { it.destination.hasRoute() }, - ) - } - - /** - * The same claim as above, but reached the way a deep link reaches it. The gate the deep link - * opens is a second entry on a destination the launch already put on the stack, so a pop that - * only reaches the nearest one leaves the first behind for back to land on. - */ - @Test - fun `back leaves the app after a deep link opened the gate`() { - val controller = navController(hasAccess = true) - controller.navigate(DEEP_LINK_URI.toUri()) - val redirect = assertNotNull(controller.currentBackStackEntry).toRoute() - - controller.navigateToValidatedImport(hasAccess = true, pending = redirect.pendingImport) - controller.navigate(SelectItemForTotpRoute(DEEP_LINK_URI)) { - popUpTo { inclusive = true } - } - - assertTrue(controller.currentDestination?.hasRoute() == true) - assertFalse( - controller.currentBackStack.value.any { it.destination.hasRoute() }, - ) - } - - /** The onboarding half of the same claim, for an account that has no access yet. */ - @Test - fun `back leaves the app after a deep link opened onboarding`() { - val controller = navController(hasAccess = false) - controller.navigate(DEEP_LINK_URI.toUri()) - val redirect = assertNotNull(controller.currentBackStackEntry).toRoute() - - controller.navigateToValidatedImport(hasAccess = false, pending = redirect.pendingImport) - controller.navigate(SelectItemForTotpRoute(DEEP_LINK_URI)) { - popUpTo { inclusive = true } - } - - assertTrue(controller.currentDestination?.hasRoute() == true) - assertFalse( - controller.currentBackStack.value.any { it.destination.hasRoute() }, - ) - } - - @Test - fun `a validated code sends an account with access to AuthRoute`() { - val controller = navController(hasAccess = true) - controller.navigate( - TotpImportRedirect(totpInfo = "Example:me@example.com", queries = "secret=ABC"), - ) - - controller.navigateToValidatedImport( - hasAccess = true, - pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = "secret=ABC"), - ) - - assertTrue(controller.currentDestination?.hasRoute() == true) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals("Example:me@example.com", route.totpInfo) - assertEquals("secret=ABC", route.queries) - assertFalse( - controller.currentBackStack.value.any { it.destination.hasRoute() }, - ) - } - - @Test - fun `a validated code sends an account without access to OnboardingRoute`() { - val controller = navController(hasAccess = false) - controller.navigate( - TotpImportRedirect(totpInfo = "Example:me@example.com", queries = "secret=ABC"), - ) - - controller.navigateToValidatedImport( - hasAccess = false, - pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = "secret=ABC"), - ) - - assertTrue(controller.currentDestination?.hasRoute() == true) - - val route = assertNotNull(controller.currentBackStackEntry).toRoute() - assertEquals("Example:me@example.com", route.totpInfo) - assertEquals("secret=ABC", route.queries) - assertFalse( - controller.currentBackStack.value.any { it.destination.hasRoute() }, - ) - } - - private companion object { - const val DEEP_LINK_URI = - "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" - } -} 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 new file mode 100644 index 000000000..9c704d80e --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -0,0 +1,304 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.compose.runtime.mutableStateOf +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.item.domain.alias.newItemId +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.settings.presentation.ChangePasswordRoute +import de.davis.keygo.feature.settings.presentation.SettingsRoute +import de.davis.keygo.feature.totp.presentation.SelectItemForTotpRoute +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AppNavigatorTest { + + private fun navigator(launchRoute: NavKey = AuthRoute()): AppNavigator { + val state = AppNavigationState( + launchStack = NavBackStack(launchRoute), + topLevelRoute = mutableStateOf(RouteDestination.Home), + backStacks = TOP_LEVEL_ROUTES.associateWith { NavBackStack(it) }, + ) + return AppNavigator(state) + } + + private val AppNavigator.shown: List + get() = if (state.isLaunching) state.launchStack.toList() + else state.backStacks.getValue(state.topLevelRoute).toList() + + // ---- the launch flow ---- + + @Test + fun `the launch flow owns the window until it finishes`() { + val navigator = navigator() + + assertTrue(navigator.state.isLaunching) + assertEquals(listOf(AuthRoute()), navigator.shown) + + navigator.finishLaunchFlow() + + assertFalse(navigator.state.isLaunching) + assertEquals(listOf(RouteDestination.Home), navigator.shown) + } + + @Test + fun `a validated code sends an account with access to the unlock gate`() { + val navigator = navigator(launchRoute = OnboardingRoute()) + + navigator.openGateFor(hasAccess = true, uri = DEEP_LINK_URI) + + assertEquals(listOf(AuthRoute(uri = DEEP_LINK_URI)), navigator.shown) + } + + @Test + fun `a validated code sends an account without access to first run`() { + val navigator = navigator() + + navigator.openGateFor(hasAccess = false, uri = DEEP_LINK_URI) + + assertEquals(listOf(OnboardingRoute(uri = DEEP_LINK_URI)), navigator.shown) + } + + @Test + fun `the picker replaces the gate, so back leaves the app`() { + val navigator = navigator() + + navigator.replaceLaunchFlow(SelectItemForTotpRoute(DEEP_LINK_URI)) + + assertEquals(listOf(SelectItemForTotpRoute(DEEP_LINK_URI)), navigator.shown) + assertEquals(1, navigator.shown.size) + } + + @Test + fun `assigning a code pushes onto the launch flow and back returns to the picker`() { + val navigator = navigator() + navigator.replaceLaunchFlow(SelectItemForTotpRoute(DEEP_LINK_URI)) + + navigator.navigate(TestAssignRoute) + assertEquals(listOf(SelectItemForTotpRoute(DEEP_LINK_URI), TestAssignRoute), navigator.shown) + + navigator.goBack() + assertEquals(listOf(SelectItemForTotpRoute(DEEP_LINK_URI)), navigator.shown) + } + + @Test + fun `back never empties the launch flow, so the app is what exits`() { + val navigator = navigator() + + navigator.goBack() + + assertTrue(navigator.state.isLaunching) + assertEquals(listOf(AuthRoute()), navigator.shown) + } + + @Test + fun `a top level route is not switched to while the launch flow is running`() { + val navigator = navigator() + + navigator.navigate(SettingsRoute) + + assertTrue(navigator.state.isLaunching) + assertEquals(listOf(AuthRoute(), SettingsRoute), navigator.shown) + } + + // ---- top level routes ---- + + @Test + fun `a top level route is the whole of what is shown, with nothing underneath it`() { + val navigator = unlocked() + + navigator.navigate(SettingsRoute) + + assertEquals(SettingsRoute, navigator.state.topLevelRoute) + assertEquals(listOf(SettingsRoute), navigator.shown) + } + + @Test + fun `coming back to a top level route lands where it was left`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + + navigator.navigate(RouteDestination.Home) + assertEquals(listOf(RouteDestination.Home), navigator.shown) + + navigator.navigate(SettingsRoute) + assertEquals(listOf(SettingsRoute, ChangePasswordRoute), navigator.shown) + } + + @Test + fun `each top level route keeps a history of its own`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.showDetail(RouteDestination.ViewItem(itemId)) + + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + assertEquals(listOf(SettingsRoute, ChangePasswordRoute), navigator.shown) + + navigator.navigate(RouteDestination.Home) + assertEquals( + listOf(RouteDestination.Home, RouteDestination.ViewItem(itemId)), + navigator.shown, + ) + } + + @Test + fun `back after switching tabs walks the history that tab kept`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + navigator.navigate(RouteDestination.Home) + + navigator.navigate(SettingsRoute) + navigator.goBack() + + assertEquals(listOf(SettingsRoute), navigator.shown) + } + + @Test + fun `picking the top level route already shown pops back to its base`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + + navigator.navigate(SettingsRoute) + + assertEquals(listOf(SettingsRoute), navigator.shown) + } + + @Test + fun `back at the base of a top level route leaves the stack for the display to exit through`() { + val navigator = unlocked() + navigator.navigate(SettingsRoute) + navigator.navigate(ChangePasswordRoute) + + navigator.goBack() + assertEquals(listOf(SettingsRoute), navigator.shown) + + navigator.goBack() + assertEquals(SettingsRoute, navigator.state.topLevelRoute) + assertEquals(listOf(SettingsRoute), navigator.shown) + } + + // ---- the detail pane ---- + + @Test + fun `picking another item swaps the detail instead of stacking one behind it`() { + val navigator = unlocked() + val first = newItemId() + val second = newItemId() + + navigator.showDetail(RouteDestination.ViewItem(first)) + navigator.showDetail(RouteDestination.ViewItem(second)) + + assertEquals( + listOf(RouteDestination.Home, RouteDestination.ViewItem(second)), + navigator.shown, + ) + } + + @Test + fun `editing stacks on the item it edits, so back returns to it`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.showDetail(RouteDestination.ViewItem(itemId)) + + navigator.openOnTopOfDetail(RouteDestination.EditItem(VaultItemType.Login, itemId)) + + assertEquals( + listOf( + RouteDestination.Home, + RouteDestination.ViewItem(itemId), + RouteDestination.EditItem(VaultItemType.Login, itemId), + ), + navigator.shown, + ) + + navigator.goBack() + assertEquals( + listOf(RouteDestination.Home, RouteDestination.ViewItem(itemId)), + navigator.shown, + ) + } + + @Test + fun `closing the detail leaves the list, however deep the detail went`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.showDetail(RouteDestination.ViewItem(itemId)) + navigator.openOnTopOfDetail(RouteDestination.EditItem(VaultItemType.Login, itemId)) + + navigator.closeDetail() + + assertEquals(listOf(RouteDestination.Home), navigator.shown) + } + + @Test + fun `a narrowing window drops a detail the list picked, but not a form`() { + val navigator = unlocked() + navigator.showDetail(RouteDestination.ViewItem(newItemId())) + + navigator.dropAutoSelectedDetail() + assertEquals(listOf(RouteDestination.Home), navigator.shown) + + navigator.showDetail(RouteDestination.CreateItem(VaultItemType.Login)) + navigator.dropAutoSelectedDetail() + + assertEquals( + listOf(RouteDestination.Home, RouteDestination.CreateItem(VaultItemType.Login)), + navigator.shown, + ) + } + + @Test + fun `a dialog over the detail leaves the pane reporting what it shows`() { + val navigator = unlocked() + val itemId = newItemId() + navigator.showDetail(RouteDestination.ViewItem(itemId)) + + navigator.navigate(RouteDestination.SelectItemType) + + // The list reads this to decide whether to pick a row itself. Reading nothing here makes + // it pick one, and that lands on top of the dialog and closes it. + assertEquals(RouteDestination.ViewItem(itemId), navigator.state.openDetail) + assertEquals( + listOf( + RouteDestination.Home, + RouteDestination.ViewItem(itemId), + RouteDestination.SelectItemType, + ), + navigator.shown, + ) + } + + @Test + fun `back at the start route leaves the stack alone for the display to exit through`() { + val navigator = unlocked() + + navigator.goBack() + + assertEquals(listOf(RouteDestination.Home), navigator.shown) + } + + private fun unlocked(): AppNavigator = navigator().apply { finishLaunchFlow() } + + private companion object { + val TOP_LEVEL_ROUTES: Set = linkedSetOf( + RouteDestination.Home, + RouteDestination.Connectivity, + SettingsRoute, + ) + + const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} + +/** Stands in for a destination pushed on top of the picker, without pulling in its screen. */ +private object TestAssignRoute : NavKey diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppShellTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppShellTest.kt new file mode 100644 index 000000000..7e30f6fa6 --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppShellTest.kt @@ -0,0 +1,107 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.feature.backup.presentation.BackupHubRoute +import de.davis.keygo.feature.settings.presentation.SettingsRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppShellTest { + + private fun entry(key: NavKey, metadata: Map): NavEntry = + NavEntry(key = key, metadata = metadata, content = {}) + + private val home = entry(RouteDestination.Home, appShell(ShellVisibility.Always)) + + private val viewItem = entry( + RouteDestination.ViewItem(newItemId()), + appShell(ShellVisibility.BesideListPane), + ) + + private val createItem = entry( + RouteDestination.CreateItem(VaultItemType.Login), + appShell(ShellVisibility.BesideListPane), + ) + + private val settings = entry(SettingsRoute, NavigationOnly) + + private val backup = entry(BackupHubRoute, WindowOwning) + + @Test + fun `the list keeps the shell at any width`() { + assertEquals( + ResolvedAppShell(showNavigation = true, showCreateButton = true), + listOf(home).resolveAppShell(listPaneVisible = true), + ) + assertEquals( + ResolvedAppShell(showNavigation = true, showCreateButton = true), + listOf(home).resolveAppShell(listPaneVisible = false), + ) + } + + @Test + fun `an opened item hides the shell only once it has the window to itself`() { + assertEquals( + ResolvedAppShell(showNavigation = true, showCreateButton = true), + listOf(home, viewItem).resolveAppShell(listPaneVisible = true), + ) + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + listOf(home, viewItem).resolveAppShell(listPaneVisible = false), + ) + } + + @Test + fun `a form follows the same rule as the item it was opened from`() { + assertEquals( + ResolvedAppShell(showNavigation = true, showCreateButton = true), + listOf(home, viewItem, createItem).resolveAppShell(listPaneVisible = true), + ) + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + listOf(home, viewItem, createItem).resolveAppShell(listPaneVisible = false), + ) + } + + @Test + fun `settings keeps the navigation but not the create button`() { + assertEquals( + ResolvedAppShell(showNavigation = true, showCreateButton = false), + listOf(home, settings).resolveAppShell(listPaneVisible = false), + ) + } + + @Test + fun `the backup flow takes the window at any width`() { + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + listOf(home, settings, backup).resolveAppShell(listPaneVisible = true), + ) + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + listOf(home, settings, backup).resolveAppShell(listPaneVisible = false), + ) + } + + @Test + fun `a destination that declares nothing shows up bare`() { + val undeclared = entry(RouteDestination.Libraries, emptyMap()) + + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + listOf(home, undeclared).resolveAppShell(listPaneVisible = true), + ) + } + + @Test + fun `an empty back stack asks for nothing`() { + assertEquals( + ResolvedAppShell(showNavigation = false, showCreateButton = false), + emptyList>().resolveAppShell(listPaneVisible = true), + ) + } +} diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt new file mode 100644 index 000000000..8e02c3657 --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt @@ -0,0 +1,111 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.core.net.toUri +import androidx.navigation3.runtime.deeplink.DeepLinkRequest +import de.davis.keygo.feature.totp.presentation.TotpImportDeepLinkMatcher +import de.davis.keygo.feature.totp.presentation.TotpImportRedirect +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** An `otpauth://` link has to reach the import gate whole: label and query parameters intact. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TotpImportDeepLinkTest { + + private fun match(uri: String): TotpImportRedirect? = + TotpImportDeepLinkMatcher.match(DeepLinkRequest(uri.toUri()))?.key + + @Test + fun `a single query parameter round trips`() { + val key = assertNotNull(match("otpauth://totp/Example:me@example.com?secret=ABC")) + + assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", key.uri) + } + + @Test + fun `every query parameter survives, not just the ones we know about`() { + val key = assertNotNull(match(DEEP_LINK_URI)) + + assertEquals(DEEP_LINK_URI, key.uri) + } + + /** + * The label is carried exactly as it arrived, because the parser this is handed to decodes + * each half of it once itself. Decoding here as well would decode it twice. + */ + @Test + fun `a percent encoded label is passed on still encoded`() { + val key = assertNotNull(match("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC")) + + assertEquals("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC", key.uri) + } + + /** + * Pins the bug: reading the decoded label put a real "#" back into the uri, which cut the + * query off as a fragment and left the import with no secret at all. + */ + @Test + fun `an escaped delimiter stays escaped instead of becoming a real one`() { + val key = assertNotNull(match("otpauth://totp/Acme%23EU:me@acme.com?secret=ABC")) + + assertEquals("otpauth://totp/Acme%23EU:me@acme.com?secret=ABC", key.uri) + } + + @Test + fun `an escaped ampersand in a query value does not split the query`() { + val key = assertNotNull(match("otpauth://totp/Example?secret=ABC&issuer=A%26B")) + + assertEquals("otpauth://totp/Example?secret=ABC&issuer=A%26B", key.uri) + } + + /** A bare "+" is a space to the parser, so one that arrived escaped has to stay escaped. */ + @Test + fun `an escaped plus in a query value does not become a space`() { + val key = assertNotNull(match("otpauth://totp/Example?secret=ABC&issuer=A%2BB")) + + assertEquals("otpauth://totp/Example?secret=ABC&issuer=A%2BB", key.uri) + } + + @Test + fun `a link with no query carries nothing to import`() { + val key = assertNotNull(match("otpauth://totp/Example:me@example.com")) + + assertNull(key.uri) + } + + @Test + fun `a link with no label carries nothing to import`() { + val key = assertNotNull(match("otpauth://totp?secret=ABC")) + + assertNull(key.uri) + } + + @Test + fun `the scheme and host are matched without regard to case`() { + val key = assertNotNull(match("OTPAUTH://TOTP/Example?secret=ABC")) + + assertEquals("otpauth://totp/Example?secret=ABC", key.uri) + } + + @Test + fun `links that are not ours do not match`() { + assertNull(match("https://example.com/totp/Example?secret=ABC")) + assertNull(match("otpauth://hotp/Example?secret=ABC&counter=1")) + } + + @Test + fun `the manifest's intent filter and the parser agree`() { + assertEquals("otpauth", TotpImportDeepLinkMatcher.SCHEME) + assertEquals("totp", TotpImportDeepLinkMatcher.HOST) + } + + private companion object { + const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7dddcfd3e..fd632063f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -10,6 +10,12 @@ android { dependencies { implementation(libs.androidx.animation.graphics) + // api: rememberNavEntryDecorators and KeyGoNavDisplay take and hand back nav3 types, so every + // consumer sees them. + api(libs.androidx.navigation3.runtime) + api(libs.androidx.navigation3.ui) + api(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(projects.core.item) } diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/RouteDestination.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/RouteDestination.kt deleted file mode 100644 index 4e535cbc8..000000000 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/RouteDestination.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.davis.keygo.core.ui - -interface RouteDestination { - - val graphDest: RouteDestination - get() = this -} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt deleted file mode 100644 index ef2a1d156..000000000 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.davis.keygo.core.ui.model - -import kotlinx.serialization.Serializable - -@Serializable -data class PendingTotpImport( - val totpInfo: String? = null, - val queries: String? = null, -) { - val uri: String? - get() = if (!totpInfo.isNullOrBlank() && !queries.isNullOrBlank()) - "otpauth://totp/$totpInfo?$queries" - else null - - companion object { - const val BASE_PATH = "otpauth://totp" - const val URI_PATTERN = "otpauth://totp/{totpInfo}?{queries}" - } -} diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/KeyGoNavDisplay.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/KeyGoNavDisplay.kt new file mode 100644 index 000000000..a2e9c5c86 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/KeyGoNavDisplay.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.core.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPopTransitionSpec + +/** + * Renders [backStack] across the whole window of its host. + * + * This is the shape the satellite activities want: one throwaway stack, no navigation bar and no + * detail pane. Back pops, and popping the last entry is what closes the host, so a flow the system + * started can always be backed out of. + * + * The app itself owns several stacks and draws its own shell around them, so it uses the [NavEntry] + * overload instead. + */ +@Composable +fun KeyGoNavDisplay( + backStack: NavBackStack, + modifier: Modifier = Modifier, + entryProvider: (NavKey) -> NavEntry, +) { + Scaffold(modifier = modifier) { innerPadding -> + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = rememberNavEntryDecorators(), + predictivePopTransitionSpec = KeyGoPredictivePopTransitionSpec, + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + entryProvider = entryProvider, + ) + } +} + +/** + * Renders entries that are already decorated, inside a shell the caller has drawn. + * + * Decorating happens outside so a caller holding more than one stack can keep each one decorated on + * its own, letting a route that is off screen hold on to its saved state and view models. Nothing + * here draws a background or handles insets: whatever the entries are placed in owns that. + */ +@Composable +fun KeyGoNavDisplay( + entries: List>, + onBack: () -> Unit, + sceneStrategies: List>, + modifier: Modifier = Modifier, +) { + NavDisplay( + entries = entries, + onBack = onBack, + sceneStrategies = sceneStrategies, + predictivePopTransitionSpec = KeyGoPredictivePopTransitionSpec, + modifier = modifier, + ) +} + +private val KeyGoPredictivePopTransitionSpec: + AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { defaultPopTransitionSpec()(this) } diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/NavEntryDecorators.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/NavEntryDecorators.kt new file mode 100644 index 000000000..0a83f874c --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/NavEntryDecorators.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.core.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + +/** + * The decorators every back stack is rendered with: each destination keeps its own saved state and + * view models while it is on the stack. Every stack gets its own call, so none of it is shared. + */ +@Composable +fun rememberNavEntryDecorators(): List> { + val saveableState = rememberSaveableStateHolderNavEntryDecorator() + val viewModelStore = rememberViewModelStoreNavEntryDecorator() + + return remember(saveableState, viewModelStore) { listOf(saveableState, viewModelStore) } +} diff --git a/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt b/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt deleted file mode 100644 index a35a443c8..000000000 --- a/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package de.davis.keygo.core.ui.model - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class PendingTotpImportTest { - - @Test - fun `uri rebuilds the full otpauth string when both parts are present`() { - val pending = PendingTotpImport( - totpInfo = "Example:me@example.com", - queries = "secret=JBSWY3DPEHPK3PXP&issuer=Example", - ) - assertEquals( - "otpauth://totp/Example:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - pending.uri, - ) - } - - @Test - fun `uri is null when totpInfo is missing`() { - val pending = PendingTotpImport(totpInfo = null, queries = "secret=ABC") - assertNull(pending.uri) - } - - @Test - fun `uri is null when queries is missing`() { - val pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = null) - assertNull(pending.uri) - } - - @Test - fun `uri is null when totpInfo is blank`() { - val pending = PendingTotpImport(totpInfo = " ", queries = "secret=ABC") - assertNull(pending.uri) - } - - @Test - fun `uri is null when queries is blank`() { - val pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = " ") - assertNull(pending.uri) - } - - @Test - fun `default construction has no pending uri`() { - assertNull(PendingTotpImport().uri) - } -} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index ab3dcdedb..c643b870d 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -13,8 +13,6 @@ dependencies { implementation(projects.core.ui) implementation(projects.legacyMigration) - implementation(libs.androidx.navigation.compose) - testImplementation(projects.rust) testImplementation(libs.robolectric) testImplementation(testFixtures(projects.core.identity)) diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthEntries.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthEntries.kt new file mode 100644 index 000000000..8db12c974 --- /dev/null +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthEntries.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.auth.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey + +fun EntryProviderScope.authEntries( + metadata: Map = emptyMap(), + onSuccess: (String?) -> Unit, +) { + entry(metadata = metadata) { route -> + AuthScreen(route = route, onSuccess = { onSuccess(route.uri) }) + } +} diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt deleted file mode 100644 index ab34ab9e7..000000000 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.davis.keygo.feature.auth.presentation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute - -fun NavGraphBuilder.authGraph(onSuccess: (String?) -> Unit) { - composable { s -> - AuthScreen( - onSuccess = { - onSuccess(s.toRoute().uri) - } - ) - } -} diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 9034b97c7..fcec2661f 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -14,10 +14,11 @@ import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.auth.presentation.model.BiometricRequest import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Composable -fun AuthScreen(onSuccess: () -> Unit) { - val viewModel = koinViewModel() +fun AuthScreen(route: AuthRoute, onSuccess: () -> Unit) { + val viewModel = koinViewModel { parametersOf(route) } val state by viewModel.uiState.collectAsStateWithLifecycle() val currentOnSuccess by rememberUpdatedState(onSuccess) diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt index 3b7a3e981..7397f78f7 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt @@ -1,10 +1,8 @@ package de.davis.keygo.feature.auth.presentation import androidx.compose.foundation.text.input.TextFieldState -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase @@ -28,12 +26,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import javax.crypto.Cipher @KoinViewModel internal class AuthViewModel( - savedStateHandle: SavedStateHandle, + @InjectedParam private val authRoute: AuthRoute, biometricAvailabilityRepository: BiometricAvailabilityRepository, accountRepository: AccountRepository, @@ -49,8 +48,6 @@ internal class AuthViewModel( private val biometricChannel = Channel(Channel.BUFFERED) val biometricFlow = biometricChannel.receiveAsFlow() - private val authRoute = savedStateHandle.toRoute() - val hasPendingTotpImport: Boolean = authRoute.uri != null private val passwordTextFieldState = TextFieldState() diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt index ea6e84bbf..7d877b4d5 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt @@ -1,23 +1,11 @@ package de.davis.keygo.feature.auth.presentation -import de.davis.keygo.core.ui.RouteDestination -import de.davis.keygo.core.ui.model.PendingTotpImport +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -/** - * The pending import travels as primitives, not as a [PendingTotpImport] field. Type-safe - * navigation has no [androidx.navigation.NavType] for a custom class unless one is supplied - * through a typeMap, and building the graph without it throws while the graph is created. - */ +/** The import travels whole: back stack keys are saved with kotlinx.serialization. */ @Serializable data class AuthRoute( - val totpInfo: String? = null, - val queries: String? = null, + val uri: String? = null, val showBiometricPromptIfPossible: Boolean = true, -) : RouteDestination { - val pendingTotpImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) - - val uri: String? - get() = pendingTotpImport.uri -} +) : NavKey diff --git a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt index f719e21d5..0439fd3bb 100644 --- a/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt +++ b/feature/auth/src/test/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModelTest.kt @@ -1,7 +1,6 @@ package de.davis.keygo.feature.auth.presentation import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.Account import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase @@ -114,7 +113,7 @@ class AuthViewModelTest { runPendingMigrationUseCase(backgroundScope, mainPasswordRepository), ): AuthViewModel { val vm = AuthViewModel( - savedStateHandle = SavedStateHandle(), + authRoute = AuthRoute(), biometricAvailabilityRepository = biometricAvailability, accountRepository = accountRepository, hasV1MainPassword = hasV1MainPassword, diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 51e1c21ef..801c137a3 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -24,7 +24,6 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.autofill) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt index 2c273329d..1a5d776d0 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.rememberNavBackStack import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter import de.davis.keygo.core.identity.presentation.useAdapter import de.davis.keygo.core.security.domain.model.BiometricPolicy @@ -118,27 +118,33 @@ internal class AutofillActivity : FragmentActivity() { viewModel.start() } - val navController = rememberNavController() if (uiState.request !is Request.None) { + val backStack = rememberNavBackStack( + AuthRoute( + showBiometricPromptIfPossible = + uiState.request !is Request.JustAuthenticateWithPwd, + ), + ) + AutofillUi( - navController = navController, + backStack = backStack, onItemSelected = { viewModel.onEvent(AutofillUiEvent.OnItemSelected(it)) }, onSaved = ::finishWithResult, abort = ::finishWithResult, onAuthenticationSucceeded = { - when (uiState.request) { + when (val request = uiState.request) { is Request.JustAuthenticateWithPwd -> viewModel.onEvent( AutofillUiEvent.OnAuthenticated ) + // The gate is replaced rather than pushed over: back from here + // leaves the activity. else -> { - navController.navigate(uiState.request.destination) { - popUpTo { inclusive = true } - } + backStack.clear() + backStack.add(request.destination) } } }, - showBiometricPromptIfPossible = uiState.request !is Request.JustAuthenticateWithPwd ) } diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillUi.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillUi.kt index 8ac9f7127..0de408e92 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillUi.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillUi.kt @@ -1,57 +1,32 @@ package de.davis.keygo.feature.autofill.presentation.activity -import android.net.Uri -import android.os.Bundle -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.toRoute +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.core.ui.navigation.KeyGoNavDisplay +import de.davis.keygo.feature.auth.presentation.authEntries import de.davis.keygo.feature.autofill.presentation.model.SaveItemDestination -import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.create.presentation.EditVaultItemScreen import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy -import de.davis.keygo.feature.list_screen.presentation.itemListGraph -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlin.reflect.typeOf +import de.davis.keygo.feature.list_screen.presentation.itemListEntries - -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AutofillUi( - navController: NavHostController, +internal fun AutofillUi( + backStack: NavBackStack, onItemSelected: (ItemId) -> Unit, onSaved: () -> Unit, abort: () -> Unit, onAuthenticationSucceeded: () -> Unit, - showBiometricPromptIfPossible: Boolean ) { - Scaffold { innerPadding -> - NavHost( - navController = navController, - startDestination = AuthRoute(showBiometricPromptIfPossible = showBiometricPromptIfPossible), - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - ) { - authGraph( - onSuccess = { - onAuthenticationSucceeded() - } - ) + KeyGoNavDisplay( + backStack = backStack, + entryProvider = entryProvider { + authEntries(onSuccess = { onAuthenticationSucceeded() }) - itemListGraph( + itemListEntries( onItemClick = onItemSelected, restrictedItemType = VaultItemType.Login, dockedSearchResults = false, @@ -60,14 +35,7 @@ fun AutofillUi( notFoundStrategy = NoItemStrategy.ShowMessage ) - composable( - typeMap = mapOf( - typeOf() to serializerNavType( - DetailPaneInformation.CreateRaw.serializer() - ) - ) - ) { s -> - val destination = s.toRoute() + entry { destination -> EditVaultItemScreen( detailPaneInformation = destination.createRaw, onCreated = { onSaved() }, @@ -75,30 +43,5 @@ fun AutofillUi( ) } } - } -} - -private val JSON = Json { - ignoreUnknownKeys = true - // important for sealed hierarchies: - classDiscriminator = "type" + ) } - -private inline fun serializerNavType( - serializer: KSerializer -): NavType = object : NavType(isNullableAllowed = false) { - override fun put(bundle: Bundle, key: String, value: T) { - bundle.putString(key, JSON.encodeToString(serializer, value)) - } - - override fun get(bundle: Bundle, key: String): T { - val s = requireNotNull(bundle.getString(key)) - return JSON.decodeFromString(serializer, s) - } - - override fun parseValue(value: String): T = - JSON.decodeFromString(serializer, Uri.decode(value)) - - override fun serializeAsValue(value: T): String = - Uri.encode(JSON.encodeToString(serializer, value)) -} \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/Request.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/Request.kt index 43f5e9f18..45109b19b 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/Request.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/Request.kt @@ -1,9 +1,10 @@ package de.davis.keygo.feature.autofill.presentation.model +import androidx.navigation3.runtime.NavKey import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.list_screen.presentation.ItemListRoute -internal sealed interface Request { +internal sealed interface Request { val destination: T data object SelectItem : Request { @@ -25,4 +26,4 @@ internal sealed interface Request { override val destination: Nothing get() = throw NotImplementedError("This should never be called") } -} \ No newline at end of file +} diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/SaveItemDestination.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/SaveItemDestination.kt index 5924eaae8..cdd6bed01 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/SaveItemDestination.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/SaveItemDestination.kt @@ -1,9 +1,10 @@ package de.davis.keygo.feature.autofill.presentation.model +import androidx.navigation3.runtime.NavKey import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import kotlinx.serialization.Serializable @Serializable internal data class SaveItemDestination( val createRaw: DetailPaneInformation.CreateRaw -) \ No newline at end of file +) : NavKey diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index c235638aa..e517e0b68 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -17,7 +17,6 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) implementation(libs.androidx.datastore) implementation(libs.androidx.work) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupEntries.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupEntries.kt new file mode 100644 index 000000000..0fe9160d9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupEntries.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.backup.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.feature.backup.presentation.export.ExportWizardScreen +import de.davis.keygo.feature.backup.presentation.hub.BackupHubScreen +import de.davis.keygo.feature.backup.presentation.import.ImportWizardScreen + +/** The backup screens are their own flow on top of settings, so they share one [metadata] set. */ +fun EntryProviderScope.backupEntries( + metadata: Map = emptyMap(), + navigateToDestination: (NavKey) -> Unit, + navigateUp: () -> Unit, +) { + entry(metadata = metadata) { + BackupHubScreen( + navigateToExport = { navigateToDestination(BackupExportRoute) }, + navigateToImport = { navigateToDestination(BackupImportRoute) }, + ) + } + + entry(metadata = metadata) { + ExportWizardScreen(navigateUp = navigateUp) + } + + entry(metadata = metadata) { + ImportWizardScreen(navigateUp = navigateUp) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt deleted file mode 100644 index eb3571e04..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt +++ /dev/null @@ -1,31 +0,0 @@ -package de.davis.keygo.feature.backup.presentation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import de.davis.keygo.feature.backup.presentation.export.ExportWizardScreen -import de.davis.keygo.feature.backup.presentation.hub.BackupHubScreen -import de.davis.keygo.feature.backup.presentation.import.ImportWizardScreen - -fun NavGraphBuilder.backupGraph( - navigateToDestination: (Any) -> Unit, - navigateUp: () -> Unit, -) { - composable { - BackupHubScreen( - navigateToExport = { - navigateToDestination(BackupExportRoute) - }, - navigateToImport = { - navigateToDestination(BackupImportRoute) - }, - ) - } - - composable { - ExportWizardScreen(navigateUp = navigateUp) - } - - composable { - ImportWizardScreen(navigateUp = navigateUp) - } -} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt index c09b00c0e..5cc272880 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt @@ -1,12 +1,13 @@ package de.davis.keygo.feature.backup.presentation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -object BackupHubRoute +object BackupHubRoute : NavKey @Serializable -object BackupExportRoute +object BackupExportRoute : NavKey @Serializable -object BackupImportRoute \ No newline at end of file +object BackupImportRoute : NavKey diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index 19dcda2f9..0af4d20b9 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -12,8 +12,6 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.credentials) implementation(libs.kotlinx.serialization.json) diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt index 6cea68dab..f22cfbcee 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt @@ -32,22 +32,23 @@ import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCreden import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter import de.davis.keygo.core.identity.presentation.useAdapter import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.BiometricString import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.ui.navigation.KeyGoNavDisplay import de.davis.keygo.core.ui.text.htmlStringResource import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.auth.presentation.authEntries import de.davis.keygo.feature.credentials.R import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState import de.davis.keygo.feature.item.create.presentation.login.LoginScreen @@ -58,10 +59,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel @Serializable -private data object ListDest +private data object ListDest : NavKey @Serializable -private data object CreateItem +private data object CreateItem : NavKey internal class CreatePasskeyActivity : FragmentActivity() { @@ -87,7 +88,7 @@ internal class CreatePasskeyActivity : FragmentActivity() { mutableStateOf(null) } - val authenticatedNavController = rememberNavController() + val authenticatedBackStack = rememberNavBackStack(ListDest) ObserveAsEvents(flow = viewModel.event) { when (it) { @@ -186,52 +187,41 @@ internal class CreatePasskeyActivity : FragmentActivity() { } SessionAuthState.NeedsPassword -> { - val authNavController = rememberNavController() - Scaffold { innerPadding -> - NavHost( - navController = authNavController, - startDestination = AuthRoute(showBiometricPromptIfPossible = false), - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - ) { - authGraph( - onSuccess = { viewModel.onUnlocked() } - ) - } - } + val authBackStack = + rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) + KeyGoNavDisplay( + backStack = authBackStack, + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } - SessionAuthState.Authenticated -> { - Scaffold { innerPadding -> - NavHost( - navController = authenticatedNavController, - startDestination = ListDest, - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - ) { - composable { - PasskeyItemListScreen( - onItemClick = viewModel::onItemClicked, - onCreateClicked = { - authenticatedNavController.navigate(CreateItem) - } - ) - } + SessionAuthState.Authenticated -> KeyGoNavDisplay( + backStack = authenticatedBackStack, + entryProvider = entryProvider { + entry { + PasskeyItemListScreen( + onItemClick = viewModel::onItemClicked, + onCreateClicked = { + authenticatedBackStack.add(CreateItem) + } + ) + } - composable { - LoginScreen( - pendingPasskeyRP = rp, - loginCreated = { - viewModel.associatePasskeyAndFinish(it) - }, - navigateBack = { cancel("User cancelled passkey creation") }, - ) - } + entry { + LoginScreen( + pendingPasskeyRP = rp, + loginCreated = { + viewModel.associatePasskeyAndFinish(it) + }, + navigateBack = { + cancel("User cancelled passkey creation") + }, + ) } - } - } + }, + ) } } } diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt index 9e1474567..3492970a8 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt @@ -5,11 +5,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.credentials.GetCredentialResponse import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential @@ -17,19 +13,20 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter import de.davis.keygo.core.identity.presentation.useAdapter import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.BiometricString import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.ui.navigation.KeyGoNavDisplay import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.auth.presentation.authEntries import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState import org.koin.androidx.viewmodel.ext.android.viewModel @@ -88,20 +85,15 @@ internal class ProvidePasskeyActivity : FragmentActivity() { } SessionAuthState.NeedsPassword -> { - val navController = rememberNavController() - Scaffold { innerPadding -> - NavHost( - navController = navController, - startDestination = AuthRoute(showBiometricPromptIfPossible = false), - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - ) { - authGraph( - onSuccess = { viewModel.onUnlocked() } - ) - } - } + val backStack = + rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) + + KeyGoNavDisplay( + backStack = backStack, + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } SessionAuthState.Authenticated -> { diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 57a45e494..2ecbe6e6e 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -12,8 +12,6 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) - implementation(projects.core.ui) implementation(projects.core.item) implementation(projects.core.security) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpGraph.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpEntries.kt similarity index 78% rename from feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpGraph.kt rename to feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpEntries.kt index a58270bc9..ce41424cf 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpGraph.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpEntries.kt @@ -1,11 +1,9 @@ package de.davis.keygo.feature.item.create.presentation.totp -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.ui.RouteDestination import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.create.presentation.login.LoginScreen import kotlinx.serialization.Serializable @@ -15,17 +13,17 @@ import java.util.UUID data class AssignTotpRoute( val totpUri: String, val itemId: String? = null, -) : RouteDestination { +) : NavKey { val selectedItemId: ItemId? get() = itemId?.let(UUID::fromString) } -fun NavGraphBuilder.assignTotpGraph( +fun EntryProviderScope.assignTotpEntries( + metadata: Map = emptyMap(), onImportFinished: () -> Unit, navigateUp: () -> Unit, ) { - composable { entry -> - val route = entry.toRoute() + entry(metadata = metadata) { route -> LoginScreen( detailPaneInformation = route.selectedItemId?.let { itemId -> DetailPaneInformation.Init.Existing( diff --git a/feature/list_screen/build.gradle.kts b/feature/list_screen/build.gradle.kts index 32362d02c..9279dfbf0 100644 --- a/feature/list_screen/build.gradle.kts +++ b/feature/list_screen/build.gradle.kts @@ -8,8 +8,6 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) - implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.core.util) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListGraph.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListEntries.kt similarity index 85% rename from feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListGraph.kt rename to feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListEntries.kt index 4e0a47bf6..b7da9b4f3 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListGraph.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListEntries.kt @@ -1,13 +1,13 @@ package de.davis.keygo.feature.list_screen.presentation import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType @OptIn(ExperimentalMaterial3Api::class) -fun NavGraphBuilder.itemListGraph( +fun EntryProviderScope.itemListEntries( onItemClick: (ItemId) -> Unit, onCreateRequest: (VaultItemType) -> Unit, onItemLongClick: (ItemId) -> Unit = {}, @@ -17,7 +17,7 @@ fun NavGraphBuilder.itemListGraph( enableSelection: Boolean = false, dockedSearchResults: Boolean = false, ) { - composable { + entry { ItemListScreen( onItemClick = onItemClick, onItemLongClick = onItemLongClick, @@ -29,4 +29,4 @@ fun NavGraphBuilder.itemListGraph( dockedSearchResults = dockedSearchResults, ) } -} \ No newline at end of file +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt index a0127c799..517a0f2a7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt @@ -40,6 +40,7 @@ fun ItemListScreen( restrictedItemType: VaultItemType? = null, notFoundStrategy: NoItemStrategy = NoItemStrategy.ShowCreateNewItemCard, suggestedItemIds: Set = emptySet(), + openItemId: ItemId? = null, autoSelectFirst: Boolean = false, enableDeletion: Boolean = true, enableSelection: Boolean = true, @@ -59,13 +60,14 @@ fun ItemListScreen( collectedState.copy(items = collectedState.items.withSuggestedFirst(suggested)) } - LaunchedEffect(autoSelectFirst) { - if (!autoSelectFirst) viewModel.resetHighlight() - } + // An empty pane is filled from the list, waiting for the items if they have not loaded yet. + // Whether an empty pane is one to fill is the caller's call and not this screen's: an empty + // pane the user has just backed out of looks exactly like one nothing has been opened in. + LaunchedEffect(uiState.items, openItemId, autoSelectFirst) { + if (!autoSelectFirst || openItemId != null) return@LaunchedEffect - LaunchedEffect(uiState.items, uiState.highlightedId, autoSelectFirst) { - if (autoSelectFirst && uiState.highlightedId == null && uiState.items.isNotEmpty()) - viewModel.onItemClick(uiState.items.first().id, forceSkipSelection = true) + val first = uiState.items.firstOrNull()?.id ?: return@LaunchedEffect + viewModel.onItemClick(first, forceSkipSelection = true) } val currentOnItemsDelete by rememberUpdatedState(onItemsDelete) @@ -107,7 +109,10 @@ fun ItemListScreen( filterBottomSheetState = filterSheetState, dockedSearchResults = dockedSearchResults, enableDeletion = enableDeletion, - autoSelectFirst = autoSelectFirst, + // Beside the list the pane is what a marked row stands for, so the mark comes straight + // from what the pane was told to show. On its own the detail is a screen of its own and + // there is nothing on the list to mark. + openedItemId = if (autoSelectFirst) openItemId else null, notFoundStrategy = notFoundStrategy, restrictedItemType = restrictedItemType, suggestedItemIds = suggested, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt index d4504a4b9..f6c8bec62 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt @@ -105,7 +105,6 @@ internal class ItemListViewModel( }.distinctUntilChanged() private val selection = MutableStateFlow(ItemSelection()) - private val highlightedId = MutableStateFlow(null) private val _isVaultFlowVisible = MutableStateFlow(false) private val _isDeleteConfirmationVisible = MutableStateFlow(false) @@ -130,16 +129,14 @@ internal class ItemListViewModel( searchState, selection, submittedSearchQuery, - highlightedId, _isVaultFlowVisible, _isDeleteConfirmationVisible, - ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, highlightedId, isVaultFlowVisible, isDeleteConfirmationVisible -> + ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible -> ListItemState( items = items, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), selection = selection, - highlightedId = highlightedId, isVaultFlowVisible = isVaultFlowVisible, isDeleteConfirmationVisible = isDeleteConfirmationVisible, vaults = vaultsAndSel.vaults, @@ -226,10 +223,6 @@ internal class ItemListViewModel( searchTextFieldState.setTextAndPlaceCursorAtEnd(submittedSearchQuery.value) } - fun resetHighlight() { - highlightedId.update { null } - } - fun onClearQuery() { searchTextFieldState.clearText() submittedSearchQuery.update { "" } @@ -272,8 +265,6 @@ internal class ItemListViewModel( // Read off the list still on screen: after the delete lands the flow has already dropped // these rows, so the survivor has to be picked before the write. val firstItemId = listItemState.value.items.firstOrNull { it.id !in deleted }?.id - if (highlightedId.value in deleted) - highlightedId.update { firstItemId } viewModelScope.launch { itemRepository.deleteItems(deleted) @@ -287,7 +278,6 @@ internal class ItemListViewModel( val isSelected = itemId in selection.value.ids updateItemSelectionState(itemId, selected = !isSelected) } else { - highlightedId.update { itemId } _event.trySend(Event.ItemSelected(itemId)) } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/RouteDestination.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/RouteDestination.kt index 87e257f4d..98fc97f27 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/RouteDestination.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/RouteDestination.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -object ItemListRoute \ No newline at end of file +object ItemListRoute : NavKey diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt index b83581ae8..226fdc008 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -66,7 +66,7 @@ internal fun ItemListContent( filterBottomSheetState: FilterBottomSheetState, dockedSearchResults: Boolean, enableDeletion: Boolean, - autoSelectFirst: Boolean, + openedItemId: ItemId?, notFoundStrategy: NoItemStrategy, restrictedItemType: VaultItemType?, suggestedItemIds: Set, @@ -242,7 +242,7 @@ internal fun ItemListContent( end = 8.dp, bottom = 96.dp, ), - openedItemId = if (autoSelectFirst) uiState.highlightedId else null, + openedItemId = openedItemId, selectedItemIds = uiState.selectedItemIds ) } @@ -278,7 +278,6 @@ private fun ItemListContentPreview() { query = "Sam" ), hasSearchQuery = false, - highlightedId = null, ) } val searchTextFieldState = rememberTextFieldState() @@ -304,7 +303,7 @@ private fun ItemListContentPreview() { filterBottomSheetState = filterBottomSheetState, dockedSearchResults = false, enableDeletion = true, - autoSelectFirst = false, + openedItemId = null, notFoundStrategy = NoItemStrategy.ShowCreateNewItemCard, restrictedItemType = null, suggestedItemIds = emptySet(), diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt index aeefd3044..466a95f23 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt @@ -12,7 +12,6 @@ internal data class ListItemState( val searchState: SearchState = SearchState(), val hasSearchQuery: Boolean = false, val selection: ItemSelection = ItemSelection(), - val highlightedId: ItemId? = null, val isVaultFlowVisible: Boolean = false, val isDeleteConfirmationVisible: Boolean = false, val vaults: List = emptyList(), 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 dddc90447..29543725f 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 @@ -16,10 +16,12 @@ 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 import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -394,6 +396,28 @@ class ItemListViewModelTest { assertEquals(emptySet(), pinnedIds()) assertFalse(vm.listItemState.value.allSelectedPinned) } + + /** + * Which row is marked as open is read off the detail pane by the screen, not kept here. All + * this owes the caller is the event that moves the pane in the first place. + */ + @Test + fun `clicking an item asks for it to be shown`() = runTest(dispatcher) { + val opened = login("Opened") + loginRepository.seed(opened, login("Other")) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + advanceUntilIdle() + + val selected = async { vm.event.first() } + advanceUntilIdle() + + vm.onItemClick(opened.id) + advanceUntilIdle() + + assertEquals(Event.ItemSelected(opened.id), selected.await()) + } } /** diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index e30014b80..758b4ec27 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -17,6 +17,4 @@ dependencies { implementation(projects.core.identity) implementation(projects.feature.backup) implementation(projects.feature.autofill) - - implementation(libs.androidx.navigation.compose) } diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingEntries.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingEntries.kt new file mode 100644 index 000000000..dbe771634 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingEntries.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +fun EntryProviderScope.onboardingEntries( + metadata: Map = emptyMap(), + onSuccess: (String?) -> Unit, +) { + entry(metadata = metadata) { route -> + OnboardingScreen(route = route, onSuccess = { onSuccess(route.uri) }) + } +} + +/** The import travels whole: back stack keys are saved with kotlinx.serialization. */ +@Serializable +data class OnboardingRoute(val uri: String? = null) : NavKey diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt deleted file mode 100644 index 70843faeb..000000000 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt +++ /dev/null @@ -1,36 +0,0 @@ -package de.davis.keygo.feature.onboarding.presentation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import de.davis.keygo.core.ui.RouteDestination -import de.davis.keygo.core.ui.model.PendingTotpImport -import kotlinx.serialization.Serializable - - -fun NavGraphBuilder.onboardingGraph(onSuccess: (String?) -> Unit) { - composable { s -> - OnboardingScreen( - onSuccess = { - onSuccess(s.toRoute().uri) - } - ) - } -} - -/** - * The pending import travels as primitives, not as a [PendingTotpImport] field. Type-safe - * navigation has no [androidx.navigation.NavType] for a custom class unless one is supplied - * through a typeMap, and building the graph without it throws while the graph is created. - */ -@Serializable -data class OnboardingRoute( - val totpInfo: String? = null, - val queries: String? = null, -) : RouteDestination { - val pendingTotpImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) - - val uri: String? - get() = pendingTotpImport.uri -} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt index 851bb037a..9e5c19c06 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt @@ -69,14 +69,15 @@ import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupAction import de.davis.keygo.feature.onboarding.presentation.model.OnboardingStepProgress import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf private const val TAG = "OnboardingScreen" private val OnboardingMaxWidth = 480.dp @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun OnboardingScreen(onSuccess: () -> Unit) { - val viewModel = koinViewModel() +fun OnboardingScreen(route: OnboardingRoute, onSuccess: () -> Unit) { + val viewModel = koinViewModel { parametersOf(route) } val state by viewModel.state.collectAsStateWithLifecycle() val stepProgress by viewModel.stepProgress.collectAsStateWithLifecycle() diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt index 594007ab4..656b8d194 100644 --- a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt @@ -2,10 +2,8 @@ package de.davis.keygo.feature.onboarding.presentation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository @@ -36,13 +34,14 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import javax.crypto.Cipher import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class OnboardingViewModel( - savedStateHandle: SavedStateHandle, + @InjectedParam private val onboardingRoute: OnboardingRoute, private val biometricAvailabilityRepository: BiometricAvailabilityRepository, private val autofillServiceRepository: AutofillServiceRepository, private val chromeAutofillRepository: ChromeAutofillRepository, @@ -51,7 +50,7 @@ internal class OnboardingViewModel( private val createAccess: CreateAccessUseCase, ) : ViewModel() { - private val hasPendingTotpImport = savedStateHandle.toRoute().uri != null + private val hasPendingTotpImport = onboardingRoute.uri != null private val stepsToSkip = MutableStateFlow>(emptySet()) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 81ee6bc56..5602ae39b 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -22,8 +22,6 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.backup) - implementation(libs.androidx.navigation.compose) - testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEntries.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEntries.kt new file mode 100644 index 000000000..17e22eaeb --- /dev/null +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEntries.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.settings.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.feature.settings.presentation.changepassword.ChangePasswordScreen +import kotlinx.serialization.Serializable + +@Serializable +object SettingsRoute : NavKey + +@Serializable +object ChangePasswordRoute : NavKey + +/** Change password is settings continued, not a flow of its own, so both share [metadata]. */ +fun EntryProviderScope.settingsEntries( + metadata: Map = emptyMap(), + onOpenChangePassword: () -> Unit, + onShowLibraries: () -> Unit, + onOpenBackup: () -> Unit, + onUp: () -> Unit, +) { + entry(metadata = metadata) { + SettingsScreen( + showLibraries = onShowLibraries, + onOpenChangePassword = onOpenChangePassword, + onOpenBackup = onOpenBackup, + ) + } + + entry(metadata = metadata) { + ChangePasswordScreen(onUp = onUp) + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt deleted file mode 100644 index c584c5ae4..000000000 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.davis.keygo.feature.settings.presentation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import de.davis.keygo.core.ui.RouteDestination -import de.davis.keygo.feature.settings.presentation.changepassword.ChangePasswordScreen -import kotlinx.serialization.Serializable - -@Serializable -object SettingsGraphRoute : RouteDestination - -@Serializable -internal object SettingsHomeRoute : RouteDestination { - override val graphDest: RouteDestination get() = SettingsGraphRoute -} - -@Serializable -object ChangePasswordRoute : RouteDestination { - override val graphDest: RouteDestination get() = SettingsGraphRoute -} - -fun NavGraphBuilder.settingsGraph( - onOpenChangePassword: () -> Unit, - onShowLibraries: () -> Unit, - onOpenBackup: () -> Unit, - onUp: () -> Unit, -) = navigation(startDestination = SettingsHomeRoute) { - composable { - SettingsScreen( - showLibraries = onShowLibraries, - onOpenChangePassword = onOpenChangePassword, - onOpenBackup = onOpenBackup, - ) - } - composable { - ChangePasswordScreen(onUp = onUp) - } -} diff --git a/feature/totp/build.gradle.kts b/feature/totp/build.gradle.kts index a03af0446..4ecca6874 100644 --- a/feature/totp/build.gradle.kts +++ b/feature/totp/build.gradle.kts @@ -28,8 +28,6 @@ dependencies { implementation(projects.core.util) implementation(projects.feature.listScreen) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.compose) implementation(libs.androidx.camera.lifecycle) diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpGraph.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpEntries.kt similarity index 55% rename from feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpGraph.kt rename to feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpEntries.kt index 51babbd14..fe5922b1e 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpGraph.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpEntries.kt @@ -1,21 +1,19 @@ package de.davis.keygo.feature.totp.presentation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.ui.RouteDestination import kotlinx.serialization.Serializable @Serializable -data class SelectItemForTotpRoute(val totpUri: String) : RouteDestination +data class SelectItemForTotpRoute(val totpUri: String) : NavKey -fun NavGraphBuilder.selectItemForTotpGraph( +fun EntryProviderScope.selectItemForTotpEntries( + metadata: Map = emptyMap(), onItemSelected: (totpUri: String, itemId: ItemId) -> Unit, onCreateNew: (totpUri: String) -> Unit, ) { - composable { entry -> - val route = entry.toRoute() + entry(metadata = metadata) { route -> SelectItemForTotpScreen( totpUri = route.totpUri, onItemSelected = { itemId -> onItemSelected(route.totpUri, itemId) }, diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportDeepLinkMatcher.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportDeepLinkMatcher.kt new file mode 100644 index 000000000..268101899 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportDeepLinkMatcher.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.totp.presentation + +import androidx.navigation3.runtime.deeplink.DeepLinkMatcher +import androidx.navigation3.runtime.deeplink.DeepLinkRequest + +/** + * Matches the `otpauth://totp` links the app registers an intent filter for. + * + * `UriDeepLinkMatcher` cannot stand in for this. It percent-decodes every value it extracts, and + * its unnamed query parameter only collects parts that carry no "=", so a real otpauth query is + * dropped whole. Both matter here, because the link has to reach the parser exactly as it arrived. + */ +object TotpImportDeepLinkMatcher : + DeepLinkMatcher>() { + + const val SCHEME = "otpauth" + const val HOST = "totp" + + private const val BASE_PATH = "$SCHEME://$HOST" + + override fun matchRequest( + request: DeepLinkRequest, + ): MatchResult? { + val uri = request.uri ?: return null + if (!uri.scheme.equals(SCHEME, ignoreCase = true)) return null + if (!uri.host.equals(HOST, ignoreCase = true)) return null + + // Both halves are read encoded and glued back together untouched. The parser on the other + // end percent-decodes each half of the label itself, exactly once. Reading the decoded + // forms here would decode it a second time, and would already have promoted an escape to + // a real delimiter on the way: a label written "Acme%23EU" comes back carrying a literal + // "#", which cuts the query off as a fragment and leaves the import with no secret at all. + val label = uri.encodedPath?.removePrefix("/")?.takeIf { it.isNotBlank() } + val query = uri.encodedQuery?.takeIf { it.isNotBlank() } + + return MatchResult( + TotpImportRedirect( + uri = if (label != null && query != null) "$BASE_PATH/$label?$query" else null, + ), + ) + } +} diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt index 655d66782..fbdecf607 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt @@ -5,47 +5,37 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink -import androidx.navigation.toRoute -import de.davis.keygo.core.ui.RouteDestination -import de.davis.keygo.core.ui.model.PendingTotpImport +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.feature.totp.presentation.component.TotpParseErrorDialog import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +/** + * The link travels whole: back stack keys are saved with kotlinx.serialization, and the parser it + * is handed to wants the uri rather than its parts. + * + * Null when [TotpImportDeepLinkMatcher] matched a link that carried no complete uri, which the + * redirect screen reports as a parse error instead of swallowing. + */ @Serializable -data class TotpImportRedirect( - val totpInfo: String? = null, - val queries: String? = null, -) : RouteDestination { - val pendingImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) -} +data class TotpImportRedirect(val uri: String? = null) : NavKey -fun NavGraphBuilder.totpImportRedirectGraph( - onValidated: (PendingTotpImport) -> Unit, +fun EntryProviderScope.totpImportRedirectEntries( + metadata: Map = emptyMap(), + onValidated: (String) -> Unit, onRejected: () -> Unit, ) { - composable( - deepLinks = listOf( - navDeepLink(basePath = PendingTotpImport.BASE_PATH) { - uriPattern = PendingTotpImport.URI_PATTERN - }, - ), - ) { entry -> - val route = entry.toRoute() - val viewModel: TotpImportRedirectViewModel = - koinViewModel { parametersOf(route.pendingImport) } + entry(metadata = metadata) { route -> + val viewModel: TotpImportRedirectViewModel = koinViewModel { parametersOf(route) } val state by viewModel.state.collectAsStateWithLifecycle() - when (state) { + when (val current = state) { TotpImportRedirectState.Validating -> Unit - TotpImportRedirectState.Valid -> LaunchedEffect(route) { - onValidated(route.pendingImport) + is TotpImportRedirectState.Valid -> LaunchedEffect(route) { + onValidated(current.uri) } TotpImportRedirectState.Invalid -> TotpParseErrorDialog( diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectState.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectState.kt index 5570aa4f1..9e9c888a9 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectState.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectState.kt @@ -4,7 +4,8 @@ internal sealed interface TotpImportRedirectState { data object Validating : TotpImportRedirectState - data object Valid : TotpImportRedirectState + /** Carries the uri that parsed, so the screen does not have to re-derive it from the route. */ + data class Valid(val uri: String) : TotpImportRedirectState data object Invalid : TotpImportRedirectState } diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModel.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModel.kt index 2d0c74b0d..bafe05dbf 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModel.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModel.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.totp.presentation import android.util.Log import androidx.lifecycle.ViewModel -import de.davis.keygo.core.ui.model.PendingTotpImport import de.davis.keygo.core.util.fold import de.davis.keygo.rust.totp.TotpService import de.davis.keygo.rust.totp.getInfoFromUriWithResult @@ -14,7 +13,7 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class TotpImportRedirectViewModel( - @InjectedParam private val pendingImport: PendingTotpImport, + @InjectedParam private val route: TotpImportRedirect, private val totpService: TotpService, ) : ViewModel() { @@ -27,13 +26,13 @@ internal class TotpImportRedirectViewModel( } private fun validate(): TotpImportRedirectState { - val uri = pendingImport.uri ?: run { + val uri = route.uri ?: run { Log.e(TAG, "Deep link carried no complete otpauth uri") return TotpImportRedirectState.Invalid } return totpService.getInfoFromUriWithResult(uri).fold( - onSuccess = { TotpImportRedirectState.Valid }, + onSuccess = { TotpImportRedirectState.Valid(uri) }, onFailure = { failure -> Log.e(TAG, "Error parsing TOTP URI: $failure") TotpImportRedirectState.Invalid diff --git a/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModelTest.kt b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModelTest.kt index 0eedbef65..37a3d90d8 100644 --- a/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModelTest.kt +++ b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModelTest.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.totp.presentation -import de.davis.keygo.core.ui.model.PendingTotpImport import de.davis.keygo.rust.FakeTotpService import de.davisalessandro.keygo.rust.Algorithm import de.davisalessandro.keygo.rust.TotpInfo @@ -17,45 +16,37 @@ class TotpImportRedirectViewModelTest { private val totpService = FakeTotpService() @Test - fun `a readable code is valid`() { + fun `a readable code is valid, and carries the uri that parsed`() { totpService.infoFromUriResult = totpInfo() - val viewModel = buildViewModel(PendingTotpImport(TOTP_INFO, QUERIES)) + val viewModel = buildViewModel(TotpImportRedirect(DEEP_LINK_URI)) - assertEquals(TotpImportRedirectState.Valid, viewModel.state.value) + assertEquals(TotpImportRedirectState.Valid(DEEP_LINK_URI), viewModel.state.value) } @Test fun `an unreadable code is invalid`() { totpService.infoFromUriResult = null - val viewModel = buildViewModel(PendingTotpImport(TOTP_INFO, QUERIES)) + val viewModel = buildViewModel(TotpImportRedirect(DEEP_LINK_URI)) assertEquals(TotpImportRedirectState.Invalid, viewModel.state.value) } + /** The matcher leaves the uri null for a link missing its label or its query. */ @Test - fun `a link with no query string is invalid`() { + fun `a link that carried no complete uri is invalid`() { totpService.infoFromUriResult = totpInfo() - val viewModel = buildViewModel(PendingTotpImport(totpInfo = TOTP_INFO, queries = null)) - - assertEquals(TotpImportRedirectState.Invalid, viewModel.state.value) - } - - @Test - fun `a link with no path is invalid`() { - totpService.infoFromUriResult = totpInfo() - - val viewModel = buildViewModel(PendingTotpImport(totpInfo = null, queries = QUERIES)) + val viewModel = buildViewModel(TotpImportRedirect()) assertEquals(TotpImportRedirectState.Invalid, viewModel.state.value) } // Helpers - private fun buildViewModel(pendingImport: PendingTotpImport) = TotpImportRedirectViewModel( - pendingImport = pendingImport, + private fun buildViewModel(route: TotpImportRedirect) = TotpImportRedirectViewModel( + route = route, totpService = totpService, ) @@ -69,7 +60,7 @@ class TotpImportRedirectViewModelTest { ) companion object { - private const val TOTP_INFO = "GitHub:me@github.com" - private const val QUERIES = "secret=JBSWY3DPEHPK3PXP&issuer=github.com" + private const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 823bc8713..053038475 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,8 @@ protoc = "4.36.0" coroutines = "1.11.0" bcrypt = "0.10.2" biometric = "1.4.0-alpha07" -navigation = "2.9.8" +nav3 = "1.2.0-beta01" +lifecycleViewmodelNav3 = "2.11.0" kotlinpoet = "2.3.0" jna = "5.19.1" nbvcxz = "1.5.1" @@ -70,10 +71,12 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha26" } -androidx-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } +androidx-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" } androidx-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } -androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigation" } +androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }