From 1695106b06ed6fa2015163cbeeba2c2f44cbf842 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 12:46:26 +0200 Subject: [PATCH 01/22] build: migrate to nav-3 dependencies --- app/build.gradle.kts | 8 +++++--- core/ui/build.gradle.kts | 4 ++++ feature/auth/build.gradle.kts | 2 +- feature/autofill/build.gradle.kts | 4 +++- feature/backup/build.gradle.kts | 2 +- feature/credentials/build.gradle.kts | 4 +++- feature/item/create/build.gradle.kts | 2 +- feature/list_screen/build.gradle.kts | 2 +- feature/onboarding/build.gradle.kts | 2 +- feature/settings/build.gradle.kts | 2 +- feature/totp/build.gradle.kts | 2 +- gradle/libs.versions.toml | 11 +++++++---- 12 files changed, 29 insertions(+), 16 deletions(-) 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/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7dddcfd3e..7494777b9 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -10,6 +10,10 @@ android { dependencies { implementation(libs.androidx.animation.graphics) + // api: rememberNavEntryDecorators hands back nav3 types, so every consumer sees them. + api(libs.androidx.navigation3.runtime) + api(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(projects.core.item) } diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index ab3dcdedb..ba953f076 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -13,7 +13,7 @@ dependencies { implementation(projects.core.ui) implementation(projects.legacyMigration) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) testImplementation(projects.rust) testImplementation(libs.robolectric) diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 51e1c21ef..b8486f17c 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -24,7 +24,9 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.autofill) diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index c235638aa..fbbb86b3a 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -17,7 +17,7 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.datastore) implementation(libs.androidx.work) diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index 19dcda2f9..c301582ef 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -12,7 +12,9 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.credentials) implementation(libs.kotlinx.serialization.json) diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 57a45e494..34d463db2 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -12,7 +12,7 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) implementation(projects.core.ui) implementation(projects.core.item) diff --git a/feature/list_screen/build.gradle.kts b/feature/list_screen/build.gradle.kts index 32362d02c..3834183a6 100644 --- a/feature/list_screen/build.gradle.kts +++ b/feature/list_screen/build.gradle.kts @@ -8,7 +8,7 @@ android { } dependencies { - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) implementation(projects.core.item) implementation(projects.core.ui) diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index e30014b80..13e987172 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -18,5 +18,5 @@ dependencies { implementation(projects.feature.backup) implementation(projects.feature.autofill) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 81ee6bc56..926a11820 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -22,7 +22,7 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.backup) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) diff --git a/feature/totp/build.gradle.kts b/feature/totp/build.gradle.kts index a03af0446..206f1d82e 100644 --- a/feature/totp/build.gradle.kts +++ b/feature/totp/build.gradle.kts @@ -28,7 +28,7 @@ dependencies { implementation(projects.core.util) implementation(projects.feature.listScreen) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.compose) 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" } From 1d170a6740eaf07bd368f0858d54a4ef59a48b1e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 18:19:03 +0200 Subject: [PATCH 02/22] refactor: replace the core:ui route contract with nav-3 keys The old `RouteDestination` interface only existed to hand nav-2 a graph destination, which nav-3 keys do not need. Its replacement is the shared entry decorators every back stack is rendered with. `URI_PATTERN` went with the nav-2 deep link syntax; the `otpauth` scheme and host it encoded are now named on their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nJBXmDWn2x1xS4dTi458g --- .../davis/keygo/core/ui/RouteDestination.kt | 7 ------- .../keygo/core/ui/model/PendingTotpImport.kt | 9 ++++++--- .../core/ui/navigation/NavEntryDecorators.kt | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) delete mode 100644 core/ui/src/main/kotlin/de/davis/keygo/core/ui/RouteDestination.kt create mode 100644 core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/NavEntryDecorators.kt 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 index ef2a1d156..415e68197 100644 --- 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 @@ -9,11 +9,14 @@ data class PendingTotpImport( ) { val uri: String? get() = if (!totpInfo.isNullOrBlank() && !queries.isNullOrBlank()) - "otpauth://totp/$totpInfo?$queries" + "$BASE_PATH/$totpInfo?$queries" else null companion object { - const val BASE_PATH = "otpauth://totp" - const val URI_PATTERN = "otpauth://totp/{totpInfo}?{queries}" + /** Must stay in sync with the `otpauth` intent filter in the app's manifest. */ + const val SCHEME = "otpauth" + const val HOST = "totp" + + const val BASE_PATH = "$SCHEME://$HOST" } } 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) } +} From c7b6e420d91ce084881b09c0942061fc79385142 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 18:19:16 +0200 Subject: [PATCH 03/22] fix: tie the list highlight to the detail pane The list kept a highlight of its own and only cleared it when auto selection turned off, so a detail dropped from the back stack behind it left a row marked as open with an empty pane beside it. `resetHighlight()` becomes `setHighlight(itemId)`, and the caller that owns the pane passes what it is showing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nJBXmDWn2x1xS4dTi458g --- .../presentation/ItemListScreen.kt | 7 ++-- .../presentation/ItemListViewModel.kt | 5 +-- .../presentation/ItemListViewModelTest.kt | 36 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) 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..e7ba93a35 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,8 +60,10 @@ fun ItemListScreen( collectedState.copy(items = collectedState.items.withSuggestedFirst(suggested)) } - LaunchedEffect(autoSelectFirst) { - if (!autoSelectFirst) viewModel.resetHighlight() + // The highlight follows the detail pane rather than keeping a note of its own, so a dropped + // detail cannot leave a row marked as open. + LaunchedEffect(openItemId, autoSelectFirst) { + viewModel.setHighlight(if (autoSelectFirst) openItemId else null) } LaunchedEffect(uiState.items, uiState.highlightedId, autoSelectFirst) { 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..19c6ae4cb 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 @@ -226,8 +226,9 @@ internal class ItemListViewModel( searchTextFieldState.setTextAndPlaceCursorAtEnd(submittedSearchQuery.value) } - fun resetHighlight() { - highlightedId.update { null } + /** Points the highlight at the item the detail pane shows, or clears it when it shows none. */ + fun setHighlight(itemId: ItemId?) { + highlightedId.update { itemId } } fun onClearQuery() { 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..885711765 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 @@ -32,6 +32,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -394,6 +395,41 @@ class ItemListViewModelTest { assertEquals(emptySet(), pinnedIds()) assertFalse(vm.listItemState.value.allSelectedPinned) } + + @Test + fun `the highlight marks the item the detail pane was told to show`() = runTest(dispatcher) { + val opened = login("Opened") + loginRepository.seed(opened, login("Other")) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + advanceUntilIdle() + + vm.setHighlight(opened.id) + advanceUntilIdle() + + assertEquals(opened.id, vm.listItemState.value.highlightedId) + } + + /** Pins the bug: a detail dropped from the back stack left a row marked as open behind it. */ + @Test + fun `a detail dropped behind the list takes the highlight with it`() = runTest(dispatcher) { + val opened = login("Opened") + loginRepository.seed(opened) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + advanceUntilIdle() + + vm.onItemClick(opened.id) + advanceUntilIdle() + assertEquals(opened.id, vm.listItemState.value.highlightedId) + + vm.setHighlight(null) + advanceUntilIdle() + + assertNull(vm.listItemState.value.highlightedId) + } } /** From 16c9246a08ed28176c59e17d550b222fa4b5c807 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 18:19:31 +0200 Subject: [PATCH 04/22] refactor: turn the feature nav graphs into entry providers Every feature exported a `NavGraphBuilder` extension that built a nav-2 graph. Each is now an `EntryProviderScope` extension adding the same screens as nav-3 entries, taking the metadata its host wants to attach to them. The activities that hosted a graph of their own (autofill, create and provide passkey) drive a `NavBackStack` and a `NavDisplay` instead of a `NavController` and a `NavHost`. Route keys move next to the entries that read them and are plain `NavKey`s. They stay flat: a back stack key is saved with kotlinx.serialization, so the pending TOTP import travels as primitives rather than as a nested field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nJBXmDWn2x1xS4dTi458g --- .../feature/auth/presentation/AuthEntries.kt | 13 +++ .../feature/auth/presentation/AuthGraph.kt | 15 --- .../feature/auth/presentation/AuthScreen.kt | 5 +- .../auth/presentation/AuthViewModel.kt | 7 +- .../auth/presentation/RouteDestination.kt | 10 +- .../auth/presentation/AuthViewModelTest.kt | 3 +- .../presentation/activity/AutofillActivity.kt | 22 ++-- .../presentation/activity/AutofillUi.kt | 107 ++++++------------ .../autofill/presentation/model/Request.kt | 5 +- .../presentation/model/SaveItemDestination.kt | 3 +- .../backup/presentation/BackupEntries.kt | 29 +++++ .../backup/presentation/BackupGraph.kt | 31 ----- .../backup/presentation/RouteDestination.kt | 7 +- .../create/activity/CreatePasskeyActivity.kt | 84 +++++++------- .../activity/ProvidePasskeyActivity.kt | 28 +++-- ...ssignTotpGraph.kt => AssignTotpEntries.kt} | 14 +-- .../{ItemListGraph.kt => ItemListEntries.kt} | 10 +- .../presentation/RouteDestination.kt | 3 +- .../presentation/OnboardingEntries.kt | 28 +++++ .../presentation/OnboardingGraph.kt | 36 ------ .../presentation/OnboardingScreen.kt | 5 +- .../presentation/OnboardingViewModel.kt | 7 +- .../settings/presentation/SettingsEntries.kt | 33 ++++++ .../settings/presentation/SettingsRoutes.kt | 39 ------- ...tpGraph.kt => SelectItemForTotpEntries.kt} | 14 +-- .../totp/presentation/TotpImportRedirect.kt | 34 +++--- 26 files changed, 273 insertions(+), 319 deletions(-) create mode 100644 feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthEntries.kt delete mode 100644 feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupEntries.kt delete mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt rename feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/{AssignTotpGraph.kt => AssignTotpEntries.kt} (78%) rename feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/{ItemListGraph.kt => ItemListEntries.kt} (85%) create mode 100644 feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingEntries.kt delete mode 100644 feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt create mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEntries.kt delete mode 100644 feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt rename feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/{SelectItemForTotpGraph.kt => SelectItemForTotpEntries.kt} (55%) 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..5297647d9 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,20 +1,16 @@ package de.davis.keygo.feature.auth.presentation -import de.davis.keygo.core.ui.RouteDestination +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.ui.model.PendingTotpImport 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 as primitives: back stack keys are saved with kotlinx.serialization. */ @Serializable data class AuthRoute( val totpInfo: String? = null, val queries: String? = null, val showBiometricPromptIfPossible: Boolean = true, -) : RouteDestination { +) : NavKey { val pendingTotpImport: PendingTotpImport get() = PendingTotpImport(totpInfo, queries) 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/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..30adf2c20 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,104 +1,61 @@ 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 androidx.navigation3.ui.NavDisplay 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.rememberNavEntryDecorators +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), + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = rememberNavEntryDecorators(), modifier = Modifier .padding(innerPadding) - .consumeWindowInsets(innerPadding) - ) { - authGraph( - onSuccess = { - onAuthenticationSucceeded() - } - ) - - itemListGraph( - onItemClick = onItemSelected, - restrictedItemType = VaultItemType.Login, - dockedSearchResults = false, - enableDeletion = false, - onCreateRequest = {}, - notFoundStrategy = NoItemStrategy.ShowMessage - ) + .consumeWindowInsets(innerPadding), + entryProvider = entryProvider { + authEntries(onSuccess = { onAuthenticationSucceeded() }) + + itemListEntries( + onItemClick = onItemSelected, + restrictedItemType = VaultItemType.Login, + dockedSearchResults = false, + enableDeletion = false, + onCreateRequest = {}, + notFoundStrategy = NoItemStrategy.ShowMessage + ) - composable( - typeMap = mapOf( - typeOf() to serializerNavType( - DetailPaneInformation.CreateRaw.serializer() + entry { destination -> + EditVaultItemScreen( + detailPaneInformation = destination.createRaw, + onCreated = { onSaved() }, + navigateBack = { abort() } ) - ) - ) { s -> - val destination = s.toRoute() - EditVaultItemScreen( - detailPaneInformation = destination.createRaw, - onCreated = { onSaved() }, - navigateBack = { abort() } - ) + } } - } + ) } } - -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/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/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..710d30bab 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,24 @@ 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 androidx.navigation3.ui.NavDisplay 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.rememberNavEntryDecorators 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 +60,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 +89,8 @@ internal class CreatePasskeyActivity : FragmentActivity() { mutableStateOf(null) } - val authenticatedNavController = rememberNavController() + val authenticatedBackStack = rememberNavBackStack(ListDest) + val entryDecorators = rememberNavEntryDecorators() ObserveAsEvents(flow = viewModel.event) { when (it) { @@ -186,50 +189,55 @@ internal class CreatePasskeyActivity : FragmentActivity() { } SessionAuthState.NeedsPassword -> { - val authNavController = rememberNavController() + val authBackStack = + rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) Scaffold { innerPadding -> - NavHost( - navController = authNavController, - startDestination = AuthRoute(showBiometricPromptIfPossible = false), + NavDisplay( + backStack = authBackStack, + onBack = { authBackStack.removeLastOrNull() }, + entryDecorators = entryDecorators, modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding), - ) { - authGraph( - onSuccess = { viewModel.onUnlocked() } - ) - } + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } } SessionAuthState.Authenticated -> { Scaffold { innerPadding -> - NavHost( - navController = authenticatedNavController, - startDestination = ListDest, + NavDisplay( + backStack = authenticatedBackStack, + onBack = { authenticatedBackStack.removeLastOrNull() }, + entryDecorators = entryDecorators, modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding), - ) { - composable { - PasskeyItemListScreen( - onItemClick = viewModel::onItemClicked, - onCreateClicked = { - authenticatedNavController.navigate(CreateItem) - } - ) - } + 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..3295f1d0e 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 @@ -17,19 +17,21 @@ 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 androidx.navigation3.ui.NavDisplay 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.rememberNavEntryDecorators 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,19 +90,21 @@ internal class ProvidePasskeyActivity : FragmentActivity() { } SessionAuthState.NeedsPassword -> { - val navController = rememberNavController() + val backStack = + rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) + Scaffold { innerPadding -> - NavHost( - navController = navController, - startDestination = AuthRoute(showBiometricPromptIfPossible = false), + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = rememberNavEntryDecorators(), modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding), - ) { - authGraph( - onSuccess = { viewModel.onUnlocked() } - ) - } + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } } 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/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/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/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..ca7e195b3 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingEntries.kt @@ -0,0 +1,28 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import de.davis.keygo.core.ui.model.PendingTotpImport +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 as primitives: back stack keys are saved with kotlinx.serialization. */ +@Serializable +data class OnboardingRoute( + val totpInfo: String? = null, + val queries: String? = null, +) : NavKey { + 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/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/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/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/TotpImportRedirect.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt index 655d66782..886480614 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 @@ -1,15 +1,13 @@ package de.davis.keygo.feature.totp.presentation +import android.net.Uri import androidx.compose.foundation.layout.fillMaxWidth 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 androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import de.davis.keygo.core.ui.model.PendingTotpImport import de.davis.keygo.feature.totp.presentation.component.TotpParseErrorDialog import kotlinx.serialization.Serializable @@ -20,23 +18,29 @@ import org.koin.core.parameter.parametersOf data class TotpImportRedirect( val totpInfo: String? = null, val queries: String? = null, -) : RouteDestination { +) : NavKey { val pendingImport: PendingTotpImport get() = PendingTotpImport(totpInfo, queries) + + companion object { + fun from(uri: Uri): TotpImportRedirect? { + if (!uri.scheme.equals(PendingTotpImport.SCHEME, ignoreCase = true)) return null + if (!uri.host.equals(PendingTotpImport.HOST, ignoreCase = true)) return null + + return TotpImportRedirect( + totpInfo = uri.path?.removePrefix("/")?.takeIf { it.isNotBlank() }, + queries = uri.query?.takeIf { it.isNotBlank() }, + ) + } + } } -fun NavGraphBuilder.totpImportRedirectGraph( +fun EntryProviderScope.totpImportRedirectEntries( + metadata: Map = emptyMap(), onValidated: (PendingTotpImport) -> Unit, onRejected: () -> Unit, ) { - composable( - deepLinks = listOf( - navDeepLink(basePath = PendingTotpImport.BASE_PATH) { - uriPattern = PendingTotpImport.URI_PATTERN - }, - ), - ) { entry -> - val route = entry.toRoute() + entry(metadata = metadata) { route -> val viewModel: TotpImportRedirectViewModel = koinViewModel { parametersOf(route.pendingImport) } val state by viewModel.state.collectAsStateWithLifecycle() From 1720edb535dcaa5dc6b954a66b7029aa10af428f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 18:19:53 +0200 Subject: [PATCH 05/22] feat: let each destination declare the shell it wants The navigation bar and the create button used to be switched on by reading the shape of the back stack, which meant every new screen had to be thought about in that one place. A destination now carries its own shell as nav-3 metadata, resolved against the current window: `Always`, `Never`, or `BesideListPane` for a detail that only keeps the shell while the list is up. A destination that declares nothing owns the whole window, so a screen added without a policy shows up bare rather than borrowing chrome it was never checked against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nJBXmDWn2x1xS4dTi458g --- .../app/presentation/navigation/AppShell.kt | 74 ++++++++++++ .../presentation/navigation/AppShellTest.kt | 107 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppShell.kt create mode 100644 app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppShellTest.kt 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/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), + ) + } +} From c6ba047a2ba09a8e1bdd60d481844893afe8e542 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 18:20:09 +0200 Subject: [PATCH 06/22] refactor: navigate with NavDisplay and a stack per top level route The single `NavHost` becomes a `NavDisplay` over state the app owns: a launch stack carrying whatever has to happen before the app proper (unlocking, first run, an incoming `otpauth://` link), and one back stack per navigation bar destination. Each destination keeps its own history, so switching tabs and coming back lands where the user left off. Picking the tab already showing is what pops it to its base. Nothing sits under a base, so back from there closes the app rather than walking out through destinations already moved on from. `AppNavigator` is the only thing that writes to those stacks, which puts the rules for what replaces what in one place: a detail swaps rather than stacks, the edit form stacks on the item it edits, and an auto-selected detail is dropped when the window narrows enough to hand it the whole screen. The dashboard's two panes are a list-detail scene over one stack instead of the old nested graph plus `DetailType`, and the item the detail pane holds is what the list highlights. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nJBXmDWn2x1xS4dTi458g --- .../keygo/app/presentation/AppDestinations.kt | 15 +- .../keygo/app/presentation/MainActivity.kt | 314 +++++------------- .../component/NavigationWrapper.kt | 53 ++- .../navigation/AppNavigationState.kt | 107 ++++++ .../presentation/navigation/AppNavigator.kt | 81 +++++ .../presentation/navigation/EntryProvider.kt | 147 ++++++++ .../presentation/model/RouteDestination.kt | 57 ++-- .../presentation/DashboardEntries.kt | 85 +++++ .../dashboard/presentation/DashboardGraph.kt | 180 ---------- .../dashboard/presentation/DetailType.kt | 26 -- .../presentation/TotpImportNavGraphTest.kt | 267 --------------- .../navigation/AppNavigatorTest.kt | 292 ++++++++++++++++ .../navigation/TotpImportDeepLinkTest.kt | 86 +++++ 13 files changed, 941 insertions(+), 769 deletions(-) create mode 100644 app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigationState.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigator.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt delete mode 100644 app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt delete mode 100644 app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt delete mode 100644 app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt create mode 100644 app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt create mode 100644 app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppDestinations.kt index 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..f24fb9d8e 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,46 @@ 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.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.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPopTransitionSpec 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.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.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 +56,93 @@ 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? = + data?.let(TotpImportRedirect::from) @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) - ) + 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) } - }, - showChrome = showChrome, - showPrimaryActionButton = showPrimaryActionButton, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - } - ) { - NavHost( - navController = navController, - startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), ) { - 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() }, + NavDisplay( + entries = entries, + onBack = { navigator.goBack() }, + sceneStrategies = sceneStrategies, + // A swipe back fades like any other pop instead of scaling the screen away. + predictivePopTransitionSpec = { defaultPopTransitionSpec()(this) }, ) } } } + +/** + * 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. + */ +@Composable +private fun DropAutoSelectedDetailWhenListLeaves( + listPaneVisible: Boolean, + navigator: AppNavigator, +) { + var wasListPaneVisible by remember { 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/NavigationWrapper.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt index d525ed786..d41a976cc 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 @@ -91,15 +91,12 @@ 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 de.davis.keygo.core.ui.R as CoreUiR @@ -108,8 +105,8 @@ 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, @@ -151,7 +148,7 @@ fun KeyGoNavigationWrapper( drawerState = drawerState ) { DrawerContent( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLevelDestination, onButtonClicked = onButtonClicked, onCloseDrawer = { @@ -183,7 +180,7 @@ fun KeyGoNavigationWrapper( } + fadeOut() ) { KeyGoNavigationSuite( - currentDestination = currentDestination, + selectedRoute = selectedRoute, layoutType = layoutType, navigateToTopLvlDestination = navigateToTopLevelDestination, onButtonClicked = onButtonClicked, @@ -295,9 +292,9 @@ 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, @@ -307,7 +304,7 @@ fun KeyGoNavigationSuite( when (layoutType) { NavigationSuiteType.NavigationBar -> { KeyGoNavigationBar( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, scrollBehavior = scrollBehavior ) @@ -315,7 +312,7 @@ fun KeyGoNavigationSuite( NavigationSuiteType.NavigationRail -> { KeyGoNavigationRail( - currentDestination = currentDestination, + selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, onButtonClicked = onButtonClicked, onOpenDrawer = onOpenDrawer, @@ -326,7 +323,7 @@ fun KeyGoNavigationSuite( NavigationSuiteType.NavigationDrawer -> { KeyGoNavigationDrawer( - currentDestination = currentDestination, + selectedRoute = selectedRoute, onButtonClicked = onButtonClicked, navigateToTopLvlDestination = navigateToTopLvlDestination, buttonContainerColor = buttonContainerColor, @@ -341,8 +338,8 @@ fun KeyGoNavigationSuite( @OptIn(ExperimentalMaterial3Api::class) @Composable fun KeyGoNavigationBar( - currentDestination: NavDestination?, - navigateToTopLvlDestination: (RouteDestination) -> Unit, + selectedRoute: NavKey?, + navigateToTopLvlDestination: (NavKey) -> Unit, scrollBehavior: BottomAppBarScrollBehavior? = null ) { NavigationBar( @@ -360,7 +357,7 @@ fun KeyGoNavigationBar( ) { 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 +374,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 +414,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 +432,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 +442,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 +453,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, @@ -535,7 +532,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 +540,6 @@ fun DrawerContent( } } -fun NavDestination?.hasRoute(dest: RouteDestination): Boolean { - return this?.hasRoute(dest::class) == true -} - @Suppress("VisualLintOverlap") @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Preview(name = "Default") @@ -557,7 +550,7 @@ private fun KeyGoNavigationWrapperPreview() { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { KeyGoNavigationWrapper( - currentDestination = null, + selectedRoute = null, 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..526e42346 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigationState.kt @@ -0,0 +1,107 @@ +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. */ + val openDetail: RouteDestination.Detail? + get() = currentStack.lastOrNull() as? RouteDestination.Detail + + /** + * 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/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt new file mode 100644 index 000000000..4370ee99f --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -0,0 +1,147 @@ +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.core.ui.model.PendingTotpImport +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 = { pending -> navigator.openGateFor(hasAccess, pending) }, + // 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, pending: PendingTotpImport) { + replaceLaunchFlow( + if (hasAccess) AuthRoute(totpInfo = pending.totpInfo, queries = pending.queries) + else OnboardingRoute(totpInfo = pending.totpInfo, queries = pending.queries), + ) +} + +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..dd15ba886 --- /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..ccb6c264a --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -0,0 +1,292 @@ +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.core.ui.model.PendingTotpImport +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()) + val pending = PendingTotpImport("Example:me@example.com", "secret=ABC") + + navigator.openGateFor(hasAccess = true, pending = pending) + + assertEquals( + listOf(AuthRoute(totpInfo = "Example:me@example.com", queries = "secret=ABC")), + navigator.shown, + ) + } + + @Test + fun `a validated code sends an account without access to first run`() { + val navigator = navigator() + val pending = PendingTotpImport("Example:me@example.com", "secret=ABC") + + navigator.openGateFor(hasAccess = false, pending = pending) + + assertEquals( + listOf(OnboardingRoute(totpInfo = "Example:me@example.com", queries = "secret=ABC")), + 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 `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/TotpImportDeepLinkTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt new file mode 100644 index 000000000..d2f80712c --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/TotpImportDeepLinkTest.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.app.presentation.navigation + +import androidx.core.net.toUri +import de.davis.keygo.core.ui.model.PendingTotpImport +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? = TotpImportRedirect.from(uri.toUri()) + + @Test + fun `a single query parameter round trips`() { + val key = assertNotNull(match("otpauth://totp/Example:me@example.com?secret=ABC")) + + assertEquals("Example:me@example.com", key.totpInfo) + assertEquals("secret=ABC", key.queries) + assertEquals( + PendingTotpImport("Example:me@example.com", "secret=ABC"), + key.pendingImport, + ) + assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", key.pendingImport.uri) + } + + @Test + fun `every query parameter survives, not just the ones we know about`() { + val key = assertNotNull(match(DEEP_LINK_URI)) + + assertEquals("GitHub:me@github.com", key.totpInfo) + assertEquals(DEEP_LINK_URI, key.pendingImport.uri) + } + + @Test + fun `a percent encoded label is decoded once`() { + val key = assertNotNull(match("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC")) + + assertEquals("GitHub:me@github.com", key.totpInfo) + } + + @Test + fun `a link with no query carries nothing to import`() { + val key = assertNotNull(match("otpauth://totp/Example:me@example.com")) + + assertNull(key.queries) + assertNull(key.pendingImport.uri) + } + + @Test + fun `a link with no label carries nothing to import`() { + val key = assertNotNull(match("otpauth://totp?secret=ABC")) + + assertNull(key.totpInfo) + assertNull(key.pendingImport.uri) + } + + @Test + fun `the scheme and host are matched without regard to case`() { + assertNotNull(match("OTPAUTH://TOTP/Example?secret=ABC")) + } + + @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", PendingTotpImport.SCHEME) + assertEquals("totp", PendingTotpImport.HOST) + } + + private companion object { + const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} From f7fff33307b76570426b5e91ba58e80c18c6fa5b Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 19:42:26 +0200 Subject: [PATCH 07/22] fix: keep selected item open on screen config change --- .../list_screen/presentation/ItemListScreen.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 e7ba93a35..65da79edb 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 @@ -60,15 +60,15 @@ fun ItemListScreen( collectedState.copy(items = collectedState.items.withSuggestedFirst(suggested)) } - // The highlight follows the detail pane rather than keeping a note of its own, so a dropped - // detail cannot leave a row marked as open. - LaunchedEffect(openItemId, autoSelectFirst) { + // The highlight follows what the pane shows, so a dropped detail cannot leave a row marked as + // open, and an empty pane is filled from the list. The highlight written here only reaches the + // pane twice is what keeps the two from disagreeing within the pass. + LaunchedEffect(uiState.items, openItemId, autoSelectFirst) { viewModel.setHighlight(if (autoSelectFirst) openItemId else null) - } - LaunchedEffect(uiState.items, uiState.highlightedId, autoSelectFirst) { - if (autoSelectFirst && uiState.highlightedId == null && uiState.items.isNotEmpty()) - viewModel.onItemClick(uiState.items.first().id, forceSkipSelection = true) + val target = if (autoSelectFirst && openItemId == null) uiState.items.firstOrNull()?.id + else null + if (target != null) viewModel.onItemClick(target, forceSkipSelection = true) } val currentOnItemsDelete by rememberUpdatedState(onItemsDelete) From bf7a3bba7761ae39734c304cf887ca6604a7c9c5 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 20:12:39 +0200 Subject: [PATCH 08/22] refactor: outsource KeyGoNavDisplay --- .../keygo/app/presentation/MainActivity.kt | 7 +- core/ui/build.gradle.kts | 4 +- .../core/ui/navigation/KeyGoNavDisplay.kt | 73 +++++++++++++++++ .../presentation/activity/AutofillUi.kt | 56 +++++-------- .../create/activity/CreatePasskeyActivity.kt | 78 +++++++------------ .../activity/ProvidePasskeyActivity.kt | 26 ++----- 6 files changed, 136 insertions(+), 108 deletions(-) create mode 100644 core/ui/src/main/kotlin/de/davis/keygo/core/ui/navigation/KeyGoNavDisplay.kt 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 f24fb9d8e..528a77bb8 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 @@ -22,8 +22,6 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.DialogSceneStrategy -import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.ui.defaultPopTransitionSpec import de.davis.keygo.app.presentation.component.KeyGoNavigationWrapper import de.davis.keygo.app.presentation.navigation.AppNavigator import de.davis.keygo.app.presentation.navigation.keyGoEntryProvider @@ -31,6 +29,7 @@ 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.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 @@ -118,12 +117,10 @@ private fun App(hasAccess: Boolean, launchRoute: NavKey) { SnackbarHost(hostState = snackbarHostState) } ) { - NavDisplay( + KeyGoNavDisplay( entries = entries, onBack = { navigator.goBack() }, sceneStrategies = sceneStrategies, - // A swipe back fades like any other pop instead of scaling the screen away. - predictivePopTransitionSpec = { defaultPopTransitionSpec()(this) }, ) } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7494777b9..fd632063f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -10,8 +10,10 @@ android { dependencies { implementation(libs.androidx.animation.graphics) - // api: rememberNavEntryDecorators hands back nav3 types, so every consumer sees them. + // 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/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/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 30adf2c20..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,25 +1,18 @@ package de.davis.keygo.feature.autofill.presentation.activity -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.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.ui.NavDisplay 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.navigation.rememberNavEntryDecorators +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.create.presentation.EditVaultItemScreen import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy import de.davis.keygo.feature.list_screen.presentation.itemListEntries -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AutofillUi( backStack: NavBackStack, @@ -28,34 +21,27 @@ internal fun AutofillUi( abort: () -> Unit, onAuthenticationSucceeded: () -> Unit, ) { - Scaffold { innerPadding -> - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = rememberNavEntryDecorators(), - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - entryProvider = entryProvider { - authEntries(onSuccess = { onAuthenticationSucceeded() }) + KeyGoNavDisplay( + backStack = backStack, + entryProvider = entryProvider { + authEntries(onSuccess = { onAuthenticationSucceeded() }) - itemListEntries( - onItemClick = onItemSelected, - restrictedItemType = VaultItemType.Login, - dockedSearchResults = false, - enableDeletion = false, - onCreateRequest = {}, - notFoundStrategy = NoItemStrategy.ShowMessage - ) + itemListEntries( + onItemClick = onItemSelected, + restrictedItemType = VaultItemType.Login, + dockedSearchResults = false, + enableDeletion = false, + onCreateRequest = {}, + notFoundStrategy = NoItemStrategy.ShowMessage + ) - entry { destination -> - EditVaultItemScreen( - detailPaneInformation = destination.createRaw, - onCreated = { onSaved() }, - navigateBack = { abort() } - ) - } + entry { destination -> + EditVaultItemScreen( + detailPaneInformation = destination.createRaw, + onCreated = { onSaved() }, + navigateBack = { abort() } + ) } - ) - } + } + ) } 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 710d30bab..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 @@ -35,14 +35,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay 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.rememberNavEntryDecorators +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 @@ -90,7 +89,6 @@ internal class CreatePasskeyActivity : FragmentActivity() { } val authenticatedBackStack = rememberNavBackStack(ListDest) - val entryDecorators = rememberNavEntryDecorators() ObserveAsEvents(flow = viewModel.event) { when (it) { @@ -191,55 +189,39 @@ internal class CreatePasskeyActivity : FragmentActivity() { SessionAuthState.NeedsPassword -> { val authBackStack = rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) - Scaffold { innerPadding -> - NavDisplay( - backStack = authBackStack, - onBack = { authBackStack.removeLastOrNull() }, - entryDecorators = entryDecorators, - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - entryProvider = entryProvider { - authEntries(onSuccess = { viewModel.onUnlocked() }) - }, - ) - } + KeyGoNavDisplay( + backStack = authBackStack, + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } - SessionAuthState.Authenticated -> { - Scaffold { innerPadding -> - NavDisplay( - backStack = authenticatedBackStack, - onBack = { authenticatedBackStack.removeLastOrNull() }, - entryDecorators = entryDecorators, - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - entryProvider = entryProvider { - entry { - PasskeyItemListScreen( - onItemClick = viewModel::onItemClicked, - onCreateClicked = { - authenticatedBackStack.add(CreateItem) - } - ) + SessionAuthState.Authenticated -> KeyGoNavDisplay( + backStack = authenticatedBackStack, + entryProvider = entryProvider { + entry { + PasskeyItemListScreen( + onItemClick = viewModel::onItemClicked, + onCreateClicked = { + authenticatedBackStack.add(CreateItem) } + ) + } - entry { - 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 3295f1d0e..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 @@ -19,13 +15,12 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay 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.rememberNavEntryDecorators +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 @@ -93,19 +88,12 @@ internal class ProvidePasskeyActivity : FragmentActivity() { val backStack = rememberNavBackStack(AuthRoute(showBiometricPromptIfPossible = false)) - Scaffold { innerPadding -> - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = rememberNavEntryDecorators(), - modifier = Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding), - entryProvider = entryProvider { - authEntries(onSuccess = { viewModel.onUnlocked() }) - }, - ) - } + KeyGoNavDisplay( + backStack = backStack, + entryProvider = entryProvider { + authEntries(onSuccess = { viewModel.onUnlocked() }) + }, + ) } SessionAuthState.Authenticated -> { From f7137ca74efa9e4a59bcd020f4a483bbb9bb03ba Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 20:14:47 +0200 Subject: [PATCH 09/22] chore: migrate deprecated code --- .../app/presentation/component/NavigationWrapper.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 d41a976cc..08b90e164 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 @@ -59,8 +59,7 @@ 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.NavigationSuiteType import androidx.compose.material3.animateFloatingActionButton @@ -83,14 +82,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout -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.navigation3.runtime.NavKey import androidx.window.core.layout.WindowSizeClass import de.davis.keygo.R @@ -118,10 +116,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 From 74afa0976f24fd8e0244cb2287d463925e7eca57 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 21:10:24 +0200 Subject: [PATCH 10/22] refactor: drop custom NavigationSuiteScaffold --- .../KeyGoNavigationSuiteScaffoldLayout.kt | 178 ------------------ .../component/NavigationWrapper.kt | 126 ++++++++----- 2 files changed, 75 insertions(+), 229 deletions(-) delete mode 100644 app/src/main/kotlin/de/davis/keygo/app/presentation/component/KeyGoNavigationSuiteScaffoldLayout.kt 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 08b90e164..49df1e748 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,5 @@ 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 androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -61,14 +54,20 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults 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.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 @@ -82,6 +81,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -138,6 +139,15 @@ fun KeyGoNavigationWrapper( val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() + val scaffoldState = rememberNavigationSuiteScaffoldState() + LaunchedEffect(showChrome) { + if (showChrome) 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 = { ModalDrawerSheet( @@ -161,38 +171,25 @@ 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( - selectedRoute = selectedRoute, - 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, + scrollBehavior = scrollBehavior + ) }, navigationSuiteType = layoutType, + state = scaffoldState, primaryActionContent = { var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } @@ -220,6 +217,7 @@ fun KeyGoNavigationWrapper( checked = fabMenuExpanded, onCheckedChange = { fabMenuExpanded = !fabMenuExpanded }, modifier = Modifier + .onSizeChanged { primaryActionHeight = it.height } .semantics { traversalIndex = -1f } @@ -257,27 +255,30 @@ fun KeyGoNavigationWrapper( } } }, - 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)) .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() + } } } ) @@ -536,6 +537,29 @@ fun DrawerContent( } } +@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) + } + +/** The padding [NavigationSuiteScaffoldLayout] places around the primary action content. */ +private val PrimaryActionContentPadding = 16.dp + @Suppress("VisualLintOverlap") @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Preview(name = "Default") From 420cf7cd9a60d0f776b1b663a14b88e08cc02bc6 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 21:42:11 +0200 Subject: [PATCH 11/22] refactor: move to state based hide navigation bar --- .../component/NavigationWrapper.kt | 133 ++++++++++++++---- 1 file changed, 105 insertions(+), 28 deletions(-) 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 49df1e748..8f8d06ef9 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,5 +1,6 @@ package de.davis.keygo.app.presentation.component +import android.view.accessibility.AccessibilityManager import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,8 +22,6 @@ 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 @@ -64,6 +63,7 @@ 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 @@ -77,11 +77,14 @@ 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 @@ -97,7 +100,7 @@ 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 kotlinx.coroutines.launch -import kotlin.math.roundToInt +import kotlin.math.sign import de.davis.keygo.core.ui.R as CoreUiR @@ -137,11 +140,30 @@ fun KeyGoNavigationWrapper( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() - val scaffoldState = rememberNavigationSuiteScaffoldState() - LaunchedEffect(showChrome) { - if (showChrome) scaffoldState.show() else scaffoldState.hide() + + val touchExplorationEnabled = rememberTouchExplorationEnabled() + val hidesOnScroll = + layoutType == NavigationSuiteType.NavigationBar && !touchExplorationEnabled + + var hiddenByScroll by remember { mutableStateOf(false) } + + // 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. + LaunchedEffect(selectedRoute, hidesOnScroll) { hiddenByScroll = false } + + val showNavigation = showChrome && !(hidesOnScroll && hiddenByScroll) + LaunchedEffect(showNavigation) { + if (showNavigation) scaffoldState.show() else scaffoldState.hide() + } + + val density = LocalDensity.current + val scrollConnection = remember(density) { + NavigationScrollConnection( + thresholdPx = with(density) { NavigationScrollThreshold.toPx() }, + onVisibilityChange = { visible -> hiddenByScroll = !visible }, + ) } // Height of the primary action button, so the snackbar can clear it. Measured on the @@ -185,7 +207,6 @@ fun KeyGoNavigationWrapper( }, buttonContainerColor = buttonContainerColor, buttonContentColor = buttonContentColor, - scrollBehavior = scrollBehavior ) }, navigationSuiteType = layoutType, @@ -260,7 +281,10 @@ fun KeyGoNavigationWrapper( Modifier .fillMaxSize() .consumeWindowInsets(navigationInsets(layoutType, scaffoldState)) - .nestedScroll(scrollBehavior.nestedScrollConnection) + .then( + if (hidesOnScroll) Modifier.nestedScroll(scrollConnection) + else Modifier + ) ) { content() @@ -286,7 +310,6 @@ fun KeyGoNavigationWrapper( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun KeyGoNavigationSuite( selectedRoute: NavKey?, @@ -296,14 +319,12 @@ fun KeyGoNavigationSuite( onOpenDrawer: () -> Unit, buttonContainerColor: Color = FloatingActionButtonDefaults.containerColor, buttonContentColor: Color = contentColorFor(buttonContainerColor), - scrollBehavior: BottomAppBarScrollBehavior? = null, ) { when (layoutType) { NavigationSuiteType.NavigationBar -> { KeyGoNavigationBar( selectedRoute = selectedRoute, navigateToTopLvlDestination = navigateToTopLvlDestination, - scrollBehavior = scrollBehavior ) } @@ -332,26 +353,12 @@ fun KeyGoNavigationSuite( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun KeyGoNavigationBar( selectedRoute: NavKey?, navigateToTopLvlDestination: (NavKey) -> Unit, - scrollBehavior: BottomAppBarScrollBehavior? = null ) { - 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 = destination.route == selectedRoute, @@ -557,9 +564,79 @@ private fun navigationInsets( 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 + + 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") From dbd0d640800d766ebf34dd13977e1728b180562e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 22:25:09 +0200 Subject: [PATCH 12/22] fix: close fab menu when fab not visible --- .../app/presentation/component/NavigationWrapper.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 8f8d06ef9..8fd3d3437 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 @@ -215,10 +215,20 @@ fun KeyGoNavigationWrapper( 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 = { From 51a1ee4c05ccdf4080bbd90f09607f4a47e09027 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 22:34:53 +0200 Subject: [PATCH 13/22] refactor: resolve todo --- .../component/NavigationWrapper.kt | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) 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 8fd3d3437..2aa6732b7 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 @@ -13,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 @@ -26,6 +25,7 @@ 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 @@ -503,32 +503,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 From 38353bdfdcd4cbf55e4d539ae6243677392e45d3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 22:56:35 +0200 Subject: [PATCH 14/22] refactor: improve openDetail logic and add test for dialog behavior --- .../navigation/AppNavigationState.kt | 11 ++++++++-- .../navigation/AppNavigatorTest.kt | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) 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 index 526e42346..5bea0a802 100644 --- 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 @@ -74,9 +74,16 @@ class AppNavigationState( 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. */ + /** + * 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.lastOrNull() as? RouteDestination.Detail + get() = currentStack.filterIsInstance().lastOrNull() /** * Turns the state into the entries the display renders. Every stack keeps its own decorators, diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt index ccb6c264a..a781de827 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -265,6 +265,27 @@ class AppNavigatorTest { ) } + @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() From 119524f0420096f1eeb28e55a3774b932f6d1dd8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 31 Aug 2026 23:39:02 +0200 Subject: [PATCH 15/22] refactor: set default selected route in KeyGoNavigationWrapper --- .../davis/keygo/app/presentation/component/NavigationWrapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2aa6732b7..e7be5fbfe 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 @@ -650,7 +650,7 @@ private fun KeyGoNavigationWrapperPreview() { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { KeyGoNavigationWrapper( - selectedRoute = null, + selectedRoute = AppDestinations.entries.first().route, navigateToTopLevelDestination = {}, onButtonClicked = {}, onItemSelected = {}, From 055cce7cc75c2c56ee469a8bfde7621087248831 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 15:50:08 +0200 Subject: [PATCH 16/22] build: cleanup deps --- feature/auth/build.gradle.kts | 2 -- feature/autofill/build.gradle.kts | 3 --- feature/backup/build.gradle.kts | 1 - feature/credentials/build.gradle.kts | 4 ---- feature/item/create/build.gradle.kts | 2 -- feature/list_screen/build.gradle.kts | 2 -- feature/onboarding/build.gradle.kts | 2 -- feature/settings/build.gradle.kts | 2 -- feature/totp/build.gradle.kts | 2 -- 9 files changed, 20 deletions(-) diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index ba953f076..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.navigation3.runtime) - testImplementation(projects.rust) testImplementation(libs.robolectric) testImplementation(testFixtures(projects.core.identity)) diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index b8486f17c..801c137a3 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -24,9 +24,6 @@ android { } dependencies { - implementation(libs.androidx.navigation3.runtime) - implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.autofill) diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index fbbb86b3a..e517e0b68 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -17,7 +17,6 @@ android { } dependencies { - implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.datastore) implementation(libs.androidx.work) diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index c301582ef..0af4d20b9 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -12,10 +12,6 @@ android { } dependencies { - implementation(libs.androidx.navigation3.runtime) - implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.androidx.credentials) implementation(libs.kotlinx.serialization.json) diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 34d463db2..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.navigation3.runtime) - implementation(projects.core.ui) implementation(projects.core.item) implementation(projects.core.security) diff --git a/feature/list_screen/build.gradle.kts b/feature/list_screen/build.gradle.kts index 3834183a6..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.navigation3.runtime) - implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.core.util) diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 13e987172..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.navigation3.runtime) } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 926a11820..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.navigation3.runtime) - testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) diff --git a/feature/totp/build.gradle.kts b/feature/totp/build.gradle.kts index 206f1d82e..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.navigation3.runtime) - implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.compose) implementation(libs.androidx.camera.lifecycle) From aa8be7ed36bfcf0f7ca57693a64e18663a057c6d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 16:53:11 +0200 Subject: [PATCH 17/22] refactor: replace autoSelectFirst with openedItemId for improved item selection logic --- .../presentation/ItemListScreen.kt | 18 ++++++----- .../presentation/ItemListViewModel.kt | 13 +------- .../components/ItemListContent.kt | 7 ++--- .../presentation/model/ListItemState.kt | 1 - .../presentation/ItemListViewModelTest.kt | 30 ++++++------------- 5 files changed, 23 insertions(+), 46 deletions(-) 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 65da79edb..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 @@ -60,15 +60,14 @@ fun ItemListScreen( collectedState.copy(items = collectedState.items.withSuggestedFirst(suggested)) } - // The highlight follows what the pane shows, so a dropped detail cannot leave a row marked as - // open, and an empty pane is filled from the list. The highlight written here only reaches the - // pane twice is what keeps the two from disagreeing within the pass. + // 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) { - viewModel.setHighlight(if (autoSelectFirst) openItemId else null) + if (!autoSelectFirst || openItemId != null) return@LaunchedEffect - val target = if (autoSelectFirst && openItemId == null) uiState.items.firstOrNull()?.id - else null - if (target != null) viewModel.onItemClick(target, forceSkipSelection = true) + val first = uiState.items.firstOrNull()?.id ?: return@LaunchedEffect + viewModel.onItemClick(first, forceSkipSelection = true) } val currentOnItemsDelete by rememberUpdatedState(onItemsDelete) @@ -110,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 19c6ae4cb..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,11 +223,6 @@ internal class ItemListViewModel( searchTextFieldState.setTextAndPlaceCursorAtEnd(submittedSearchQuery.value) } - /** Points the highlight at the item the detail pane shows, or clears it when it shows none. */ - fun setHighlight(itemId: ItemId?) { - highlightedId.update { itemId } - } - fun onClearQuery() { searchTextFieldState.clearText() submittedSearchQuery.update { "" } @@ -273,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) @@ -288,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/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 885711765..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 @@ -32,7 +34,6 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -396,8 +397,12 @@ class ItemListViewModelTest { 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 `the highlight marks the item the detail pane was told to show`() = runTest(dispatcher) { + fun `clicking an item asks for it to be shown`() = runTest(dispatcher) { val opened = login("Opened") loginRepository.seed(opened, login("Other")) @@ -405,30 +410,13 @@ class ItemListViewModelTest { backgroundScope.launchCollect(vm) advanceUntilIdle() - vm.setHighlight(opened.id) - advanceUntilIdle() - - assertEquals(opened.id, vm.listItemState.value.highlightedId) - } - - /** Pins the bug: a detail dropped from the back stack left a row marked as open behind it. */ - @Test - fun `a detail dropped behind the list takes the highlight with it`() = runTest(dispatcher) { - val opened = login("Opened") - loginRepository.seed(opened) - - val vm = viewModel() - backgroundScope.launchCollect(vm) + val selected = async { vm.event.first() } advanceUntilIdle() vm.onItemClick(opened.id) advanceUntilIdle() - assertEquals(opened.id, vm.listItemState.value.highlightedId) - - vm.setHighlight(null) - advanceUntilIdle() - assertNull(vm.listItemState.value.highlightedId) + assertEquals(Event.ItemSelected(opened.id), selected.await()) } } From 92d69f0fbebf087f3d7b6d21fc24e1da3a283813 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 16:55:08 +0200 Subject: [PATCH 18/22] refactor: enhance scroll behavior in NavigationWrapper for improved visibility management --- .../component/NavigationWrapper.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) 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 e7be5fbfe..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 @@ -148,16 +148,6 @@ fun KeyGoNavigationWrapper( var hiddenByScroll by remember { mutableStateOf(false) } - // 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. - LaunchedEffect(selectedRoute, hidesOnScroll) { hiddenByScroll = false } - - val showNavigation = showChrome && !(hidesOnScroll && hiddenByScroll) - LaunchedEffect(showNavigation) { - if (showNavigation) scaffoldState.show() else scaffoldState.hide() - } - val density = LocalDensity.current val scrollConnection = remember(density) { NavigationScrollConnection( @@ -166,6 +156,21 @@ fun KeyGoNavigationWrapper( ) } + // 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) } @@ -278,10 +283,10 @@ fun KeyGoNavigationWrapper( icon = { Icon( imageVector = icon, - contentDescription = null + contentDescription = null, ) }, - text = { Text(text = text) } + text = { Text(text = text) }, ) } } @@ -581,6 +586,11 @@ private class NavigationScrollConnection( 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, From 7c49e611e8e90adf0a02986f6484c5f4077ad2a8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 17:15:00 +0200 Subject: [PATCH 19/22] refactor: fix formatting issues in EntryProvider for improved code consistency --- .../keygo/app/presentation/navigation/EntryProvider.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt index 4370ee99f..84be827fd 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -88,7 +88,7 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> onSelect = { type -> navigator.goBack() navigator.showDetail(RouteDestination.CreateItem(type)) - } + }, ) } @@ -109,24 +109,24 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> entry(metadata = NavigationOnly) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Text( text = stringResource(id = R.string.coming_soon), - style = MaterialTheme.typography.displaySmall + style = MaterialTheme.typography.displaySmall, ) } } entry(metadata = WindowOwning) { Scaffold( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { innerPadding -> val libs by produceLibraries() LibrariesContainer( libraries = libs, modifier = Modifier.fillMaxSize(), - contentPadding = innerPadding + contentPadding = innerPadding, ) } } From 4225cc43ae03e680a59543be425f6b24ef432b56 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 17:44:35 +0200 Subject: [PATCH 20/22] refactor: update remember to rememberSaveable for improved state preservation --- .../de/davis/keygo/app/presentation/MainActivity.kt | 9 +++++++-- .../keygo/dashboard/presentation/DashboardEntries.kt | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 528a77bb8..74bbba62f 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 @@ -17,6 +17,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity @@ -115,7 +116,7 @@ private fun App(hasAccess: Boolean, launchRoute: NavKey) { showPrimaryActionButton = shell.showCreateButton, snackbarHost = { SnackbarHost(hostState = snackbarHostState) - } + }, ) { KeyGoNavDisplay( entries = entries, @@ -130,13 +131,17 @@ private fun App(hasAccess: Boolean, launchRoute: NavKey) { * 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 remember { mutableStateOf(listPaneVisible) } + var wasListPaneVisible by rememberSaveable { mutableStateOf(listPaneVisible) } LaunchedEffect(listPaneVisible) { val listPaneLeft = wasListPaneVisible && !listPaneVisible wasListPaneVisible = listPaneVisible 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 index dd15ba886..cbb6df8f6 100644 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt +++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt @@ -56,10 +56,10 @@ fun EntryProviderScope.dashboardEntries(navigator: AppNavigator) { NavigationEvent.NavigateBack -> navigator.goBack() is NavigationEvent.NavigateToEdit -> navigator.openOnTopOfDetail( - RouteDestination.EditItem(event.vaultType, event.itemId) + RouteDestination.EditItem(event.vaultType, event.itemId), ) } - } + }, ) } From 2bb603aeafcac743f10b3fb8525ef62aeb65a6d3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 17:56:43 +0200 Subject: [PATCH 21/22] refactor: update TOTP import handling to preserve encoded characters for improved URI integrity --- .../navigation/TotpImportDeepLinkTest.kt | 42 ++++++++++++++++++- .../totp/presentation/TotpImportRedirect.kt | 10 ++++- 2 files changed, 48 insertions(+), 4 deletions(-) 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 index d2f80712c..2cbee3319 100644 --- 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 @@ -39,11 +39,49 @@ class TotpImportDeepLinkTest { assertEquals(DEEP_LINK_URI, key.pendingImport.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 decoded once`() { + fun `a percent encoded label is passed on still encoded`() { val key = assertNotNull(match("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC")) - assertEquals("GitHub:me@github.com", key.totpInfo) + assertEquals("GitHub%3Ame%40github.com", key.totpInfo) + assertEquals( + "otpauth://totp/GitHub%3Ame%40github.com?secret=ABC", + key.pendingImport.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("Acme%23EU:me@acme.com", key.totpInfo) + assertEquals( + "otpauth://totp/Acme%23EU:me@acme.com?secret=ABC", + key.pendingImport.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("secret=ABC&issuer=A%26B", key.queries) + } + + /** 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("secret=ABC&issuer=A%2BB", key.queries) } @Test 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 886480614..46e6f8ba5 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 @@ -27,9 +27,15 @@ data class TotpImportRedirect( if (!uri.scheme.equals(PendingTotpImport.SCHEME, ignoreCase = true)) return null if (!uri.host.equals(PendingTotpImport.HOST, ignoreCase = true)) return null + // Both halves are carried as they arrived. PendingTotpImport.uri glues them back + // into a uri, and 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. return TotpImportRedirect( - totpInfo = uri.path?.removePrefix("/")?.takeIf { it.isNotBlank() }, - queries = uri.query?.takeIf { it.isNotBlank() }, + totpInfo = uri.encodedPath?.removePrefix("/")?.takeIf { it.isNotBlank() }, + queries = uri.encodedQuery?.takeIf { it.isNotBlank() }, ) } } From afb03115a6f6d8648c435aca315eda7885ba1062 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 1 Sep 2026 19:54:28 +0200 Subject: [PATCH 22/22] refactor: replace PendingTotpImport with uri in navigation logic for improved deep link handling --- .../keygo/app/presentation/MainActivity.kt | 9 +++- .../presentation/navigation/EntryProvider.kt | 10 ++-- .../navigation/AppNavigatorTest.kt | 17 ++----- .../navigation/TotpImportDeepLinkTest.kt | 47 +++++++----------- .../keygo/core/ui/model/PendingTotpImport.kt | 22 --------- .../core/ui/model/PendingTotpImportTest.kt | 49 ------------------- .../auth/presentation/RouteDestination.kt | 14 ++---- .../presentation/OnboardingEntries.kt | 14 +----- .../presentation/TotpImportDeepLinkMatcher.kt | 42 ++++++++++++++++ .../totp/presentation/TotpImportRedirect.kt | 46 +++++------------ .../presentation/TotpImportRedirectState.kt | 3 +- .../TotpImportRedirectViewModel.kt | 7 ++- .../TotpImportRedirectViewModelTest.kt | 31 +++++------- 13 files changed, 107 insertions(+), 204 deletions(-) delete mode 100644 core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt delete mode 100644 core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt create mode 100644 feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportDeepLinkMatcher.kt 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 74bbba62f..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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity 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 @@ -37,6 +38,7 @@ import de.davis.keygo.core.util.presentation.snackbar.LocalSnackbarManager import de.davis.keygo.core.util.presentation.snackbar.SnackbarHandler import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute +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 @@ -74,8 +76,11 @@ class MainActivity : FragmentActivity() { intent.totpImportRedirect() ?: if (hasAccess) AuthRoute() else OnboardingRoute() } -private fun Intent.totpImportRedirect(): TotpImportRedirect? = - data?.let(TotpImportRedirect::from) +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 diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt index 84be827fd..64fa44276 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/navigation/EntryProvider.kt @@ -20,7 +20,6 @@ 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.core.ui.model.PendingTotpImport import de.davis.keygo.dashboard.presentation.dashboardEntries import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.auth.presentation.authEntries @@ -46,7 +45,7 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> return entryProvider { totpImportRedirectEntries( metadata = WindowOwning, - onValidated = { pending -> navigator.openGateFor(hasAccess, pending) }, + 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() @@ -134,11 +133,8 @@ fun keyGoEntryProvider(navigator: AppNavigator, hasAccess: Boolean): (NavKey) -> } /** Replaces the launch flow, so back from the gate leaves the app rather than a consumed link. */ -internal fun AppNavigator.openGateFor(hasAccess: Boolean, pending: PendingTotpImport) { - replaceLaunchFlow( - if (hasAccess) AuthRoute(totpInfo = pending.totpInfo, queries = pending.queries) - else OnboardingRoute(totpInfo = pending.totpInfo, queries = pending.queries), - ) +internal fun AppNavigator.openGateFor(hasAccess: Boolean, uri: String) { + replaceLaunchFlow(if (hasAccess) AuthRoute(uri = uri) else OnboardingRoute(uri = uri)) } private fun AppNavigator.finishUnlock(totpUri: String?) { diff --git a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt index a781de827..9c704d80e 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/navigation/AppNavigatorTest.kt @@ -6,7 +6,6 @@ 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.core.ui.model.PendingTotpImport import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute @@ -50,27 +49,19 @@ class AppNavigatorTest { @Test fun `a validated code sends an account with access to the unlock gate`() { val navigator = navigator(launchRoute = OnboardingRoute()) - val pending = PendingTotpImport("Example:me@example.com", "secret=ABC") - navigator.openGateFor(hasAccess = true, pending = pending) + navigator.openGateFor(hasAccess = true, uri = DEEP_LINK_URI) - assertEquals( - listOf(AuthRoute(totpInfo = "Example:me@example.com", queries = "secret=ABC")), - navigator.shown, - ) + 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() - val pending = PendingTotpImport("Example:me@example.com", "secret=ABC") - navigator.openGateFor(hasAccess = false, pending = pending) + navigator.openGateFor(hasAccess = false, uri = DEEP_LINK_URI) - assertEquals( - listOf(OnboardingRoute(totpInfo = "Example:me@example.com", queries = "secret=ABC")), - navigator.shown, - ) + assertEquals(listOf(OnboardingRoute(uri = DEEP_LINK_URI)), navigator.shown) } @Test 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 index 2cbee3319..8e02c3657 100644 --- 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 @@ -1,7 +1,8 @@ package de.davis.keygo.app.presentation.navigation import androidx.core.net.toUri -import de.davis.keygo.core.ui.model.PendingTotpImport +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 @@ -16,27 +17,21 @@ import kotlin.test.assertNull @Config(sdk = [34]) class TotpImportDeepLinkTest { - private fun match(uri: String): TotpImportRedirect? = TotpImportRedirect.from(uri.toUri()) + 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("Example:me@example.com", key.totpInfo) - assertEquals("secret=ABC", key.queries) - assertEquals( - PendingTotpImport("Example:me@example.com", "secret=ABC"), - key.pendingImport, - ) - assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", key.pendingImport.uri) + 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("GitHub:me@github.com", key.totpInfo) - assertEquals(DEEP_LINK_URI, key.pendingImport.uri) + assertEquals(DEEP_LINK_URI, key.uri) } /** @@ -47,11 +42,7 @@ class TotpImportDeepLinkTest { fun `a percent encoded label is passed on still encoded`() { val key = assertNotNull(match("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC")) - assertEquals("GitHub%3Ame%40github.com", key.totpInfo) - assertEquals( - "otpauth://totp/GitHub%3Ame%40github.com?secret=ABC", - key.pendingImport.uri, - ) + assertEquals("otpauth://totp/GitHub%3Ame%40github.com?secret=ABC", key.uri) } /** @@ -62,18 +53,14 @@ class TotpImportDeepLinkTest { 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("Acme%23EU:me@acme.com", key.totpInfo) - assertEquals( - "otpauth://totp/Acme%23EU:me@acme.com?secret=ABC", - key.pendingImport.uri, - ) + 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("secret=ABC&issuer=A%26B", key.queries) + 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. */ @@ -81,28 +68,28 @@ class TotpImportDeepLinkTest { 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("secret=ABC&issuer=A%2BB", key.queries) + 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.queries) - assertNull(key.pendingImport.uri) + assertNull(key.uri) } @Test fun `a link with no label carries nothing to import`() { val key = assertNotNull(match("otpauth://totp?secret=ABC")) - assertNull(key.totpInfo) - assertNull(key.pendingImport.uri) + assertNull(key.uri) } @Test fun `the scheme and host are matched without regard to case`() { - assertNotNull(match("OTPAUTH://TOTP/Example?secret=ABC")) + val key = assertNotNull(match("OTPAUTH://TOTP/Example?secret=ABC")) + + assertEquals("otpauth://totp/Example?secret=ABC", key.uri) } @Test @@ -113,8 +100,8 @@ class TotpImportDeepLinkTest { @Test fun `the manifest's intent filter and the parser agree`() { - assertEquals("otpauth", PendingTotpImport.SCHEME) - assertEquals("totp", PendingTotpImport.HOST) + assertEquals("otpauth", TotpImportDeepLinkMatcher.SCHEME) + assertEquals("totp", TotpImportDeepLinkMatcher.HOST) } private companion object { 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 415e68197..000000000 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt +++ /dev/null @@ -1,22 +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()) - "$BASE_PATH/$totpInfo?$queries" - else null - - companion object { - /** Must stay in sync with the `otpauth` intent filter in the app's manifest. */ - const val SCHEME = "otpauth" - const val HOST = "totp" - - const val BASE_PATH = "$SCHEME://$HOST" - } -} 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/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 5297647d9..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,19 +1,11 @@ package de.davis.keygo.feature.auth.presentation import androidx.navigation3.runtime.NavKey -import de.davis.keygo.core.ui.model.PendingTotpImport import kotlinx.serialization.Serializable -/** The import travels as primitives: back stack keys are saved with kotlinx.serialization. */ +/** 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, -) : NavKey { - val pendingTotpImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) - - val uri: String? - get() = pendingTotpImport.uri -} +) : NavKey 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 index ca7e195b3..dbe771634 100644 --- 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 @@ -2,7 +2,6 @@ package de.davis.keygo.feature.onboarding.presentation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import de.davis.keygo.core.ui.model.PendingTotpImport import kotlinx.serialization.Serializable fun EntryProviderScope.onboardingEntries( @@ -14,15 +13,6 @@ fun EntryProviderScope.onboardingEntries( } } -/** The import travels as primitives: back stack keys are saved with kotlinx.serialization. */ +/** The import travels whole: back stack keys are saved with kotlinx.serialization. */ @Serializable -data class OnboardingRoute( - val totpInfo: String? = null, - val queries: String? = null, -) : NavKey { - val pendingTotpImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) - - val uri: String? - get() = pendingTotpImport.uri -} +data class OnboardingRoute(val uri: String? = null) : NavKey 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 46e6f8ba5..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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.totp.presentation -import android.net.Uri import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -8,54 +7,35 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import de.davis.keygo.core.ui.model.PendingTotpImport 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, -) : NavKey { - val pendingImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) - - companion object { - fun from(uri: Uri): TotpImportRedirect? { - if (!uri.scheme.equals(PendingTotpImport.SCHEME, ignoreCase = true)) return null - if (!uri.host.equals(PendingTotpImport.HOST, ignoreCase = true)) return null - - // Both halves are carried as they arrived. PendingTotpImport.uri glues them back - // into a uri, and 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. - return TotpImportRedirect( - totpInfo = uri.encodedPath?.removePrefix("/")?.takeIf { it.isNotBlank() }, - queries = uri.encodedQuery?.takeIf { it.isNotBlank() }, - ) - } - } -} +data class TotpImportRedirect(val uri: String? = null) : NavKey fun EntryProviderScope.totpImportRedirectEntries( metadata: Map = emptyMap(), - onValidated: (PendingTotpImport) -> Unit, + onValidated: (String) -> Unit, onRejected: () -> Unit, ) { entry(metadata = metadata) { route -> - val viewModel: TotpImportRedirectViewModel = - koinViewModel { parametersOf(route.pendingImport) } + 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" } }