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 119e8637d..017eec32a 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,6 +1,8 @@ package de.davis.keygo.app.presentation 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 @@ -24,8 +26,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 @@ -37,6 +41,7 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import de.davis.keygo.R import de.davis.keygo.app.presentation.component.KeyGoNavigationWrapper import de.davis.keygo.core.presentation.model.RouteDestination +import de.davis.keygo.core.ui.model.PendingTotpImport 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 @@ -47,15 +52,22 @@ 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 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() @@ -86,11 +98,45 @@ class MainActivity : FragmentActivity() { } } +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 } + } +} + +/** + * 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 + } +} + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable private fun App(hasAccess: Boolean) { val listNavigator = rememberListDetailPaneScaffoldNavigator() val navController = rememberNavController() + val activity = LocalActivity.current val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -145,21 +191,43 @@ private fun App(hasAccess: Boolean) { startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), ) { totpImportRedirectGraph( - hasAccess = hasAccess, - navigateAndReplace = { dest -> - navController.navigate(dest) { - popUpTo { inclusive = true } + 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 -> - val dest = totpUri?.let { - RouteDestination.Home.Root(it) - } ?: RouteDestination.TopLevelAppGraph - - navController.navigate(dest) { + navController.navigate(destinationAfterUnlock(totpUri)) { popUpTo { inclusive = true } } } @@ -167,11 +235,7 @@ private fun App(hasAccess: Boolean) { onboardingGraph( onSuccess = { totpUri -> - val dest = totpUri?.let { - RouteDestination.Home.Root(it) - } ?: RouteDestination.TopLevelAppGraph - - navController.navigate(dest) { + navController.navigate(destinationAfterUnlock(totpUri)) { popUpTo { inclusive = true } } } @@ -181,7 +245,7 @@ private fun App(hasAccess: Boolean) { startDestination = RouteDestination.Home.NavGraph ) { navigation( - startDestination = RouteDestination.Home.Root() + startDestination = RouteDestination.Home.Root ) { dialog { SelectItemContent( diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/TotpImportRedirect.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/TotpImportRedirect.kt deleted file mode 100644 index 60a420298..000000000 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/TotpImportRedirect.kt +++ /dev/null @@ -1,42 +0,0 @@ -package de.davis.keygo.app.presentation - -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink -import androidx.navigation.toRoute -import de.davis.keygo.core.ui.RouteDestination -import de.davis.keygo.core.ui.model.PendingTotpImport -import de.davis.keygo.feature.auth.presentation.AuthRoute -import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute -import kotlinx.serialization.Serializable - -@Serializable -data class TotpImportRedirect( - val totpInfo: String? = null, - val queries: String? = null, -) : RouteDestination { - val pendingImport: PendingTotpImport - get() = PendingTotpImport(totpInfo, queries) -} - -fun NavGraphBuilder.totpImportRedirectGraph( - hasAccess: Boolean, - navigateAndReplace: (Any) -> Unit, -) { - composable( - deepLinks = listOf( - navDeepLink(basePath = PendingTotpImport.BASE_PATH) { - uriPattern = PendingTotpImport.URI_PATTERN - } - ) - ) { entry -> - val route = entry.toRoute() - LaunchedEffect(route) { - navigateAndReplace( - if (hasAccess) AuthRoute(totpInfo = route.totpInfo, queries = route.queries) - else OnboardingRoute(totpInfo = route.totpInfo, queries = route.queries) - ) - } - } -} 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 deba4ca0f..d525ed786 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 @@ -206,6 +206,10 @@ fun KeyGoNavigationWrapper( FloatingActionButtonMenu( expanded = fabMenuExpanded, + modifier = Modifier.animateFloatingActionButton( + visible = (showChrome && showPrimaryActionButton) || fabMenuExpanded, + alignment = Alignment.BottomEnd, + ), button = { TooltipBox( positionProvider = @@ -226,10 +230,6 @@ fun KeyGoNavigationWrapper( .semantics { traversalIndex = -1f } - .animateFloatingActionButton( - visible = (showChrome && showPrimaryActionButton) || fabMenuExpanded, - alignment = Alignment.BottomEnd, - ) .focusRequester(focusRequester), ) { val imageVector by remember { 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 fb3e4bb33..21399bbc3 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 @@ -20,7 +20,7 @@ sealed interface RouteDestination : UiRouteDestination { data object NavGraph : Home @Serializable - data class Root(val totpUri: String? = null) : Home + data object Root : Home @Serializable data object SelectItem : Home 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 index a6b867980..5e0f4ca57 100644 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt +++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt @@ -19,7 +19,6 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import androidx.navigation.toRoute 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 @@ -58,16 +57,6 @@ fun NavGraphBuilder.dashboardGraph( } } - val route = it.toRoute() - LaunchedEffect(route) { - route.totpUri?.let { totpUri -> - listNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - DetailType.Modify.Totp(totpUri) - ) - } - } - CompositionLocalProvider( LocalIsInSinglePaneMode provides isSinglePaneMode, ) { 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 index 9f9bf47e8..09a058634 100644 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt +++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DetailType.kt @@ -4,7 +4,6 @@ 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.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -16,11 +15,6 @@ sealed interface DetailType : Parcelable { data class CreateNew(override val vaultItemType: VaultItemType) : Modify data class Edit(override val vaultItemType: VaultItemType, val itemId: ItemId) : Modify - data class Totp(val uri: String) : Modify { - - @IgnoredOnParcel - override val vaultItemType: VaultItemType = VaultItemType.Login - } } data class View(val itemId: ItemId) : DetailType @@ -29,5 +23,4 @@ sealed interface DetailType : Parcelable { fun DetailType.Modify.asDetailPaneInformation() = when (this) { is DetailType.Modify.CreateNew -> DetailPaneInformation.Init.New(vaultItemType) is DetailType.Modify.Edit -> DetailPaneInformation.Init.Existing(vaultItemType, itemId) - is DetailType.Modify.Totp -> DetailPaneInformation.Init.TOTP(vaultItemType, uri) } \ 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 index b6c381712..ec7188cb1 100644 --- a/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt @@ -8,16 +8,24 @@ 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 @@ -35,7 +43,9 @@ class TotpImportNavGraphTest { controller.graph = controller.createGraph( startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), ) { - totpImportRedirectGraph(hasAccess = hasAccess, navigateAndReplace = {}) + totpImportRedirectGraph(onValidated = {}, onRejected = {}) + selectItemForTotpGraph(onItemSelected = { _, _ -> }, onCreateNew = {}) + assignTotpGraph(onImportFinished = {}, navigateUp = {}) authGraph(onSuccess = {}) onboardingGraph(onSuccess = {}) } @@ -117,4 +127,141 @@ class TotpImportNavGraphTest { 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/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt index 5cc9f5a53..1c3299df1 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.PushPin import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -55,6 +56,7 @@ import kotlin.math.roundToInt sealed interface HeaderContent { data class Letter(val char: Char) : HeaderContent data object Pin : HeaderContent + data object Suggested : HeaderContent } data class KeyGoColumnItem( @@ -294,6 +296,13 @@ private fun KeyGoInlineHeader( tint = color, modifier = Modifier.size(20.dp), ) + + is HeaderContent.Suggested -> Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp), + ) } } } diff --git a/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeRegistrableDomainResolver.kt b/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeRegistrableDomainResolver.kt new file mode 100644 index 000000000..f5983f8fd --- /dev/null +++ b/core/util/src/testFixtures/kotlin/de/davis/keygo/core/util/FakeRegistrableDomainResolver.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.core.util + +import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver + +/** + * Answers as a resolver that can see only the last two labels of a host, which is enough for the + * domains tests use. + * + * Seed [resolutions] to pin an exact answer for one domain, a null one included. A host the real + * resolver cannot place is a case callers have to carry through, and an explicit null is the only + * way to stage it for a host the label heuristic would otherwise resolve. Anything unseeded falls + * back to the heuristic, so a test names only the domains it cares about. + * + * [resolvedDomains] records every domain asked about, in order, for tests where resolving nothing + * at all is the behaviour under test. + */ +class FakeRegistrableDomainResolver : RegistrableDomainResolver { + + var resolutions: Map = emptyMap() + + val resolvedDomains: MutableList = mutableListOf() + + override fun resolve(domain: String): String? { + resolvedDomains += domain + + if (resolutions.containsKey(domain)) return resolutions[domain] + + val labels = domain.substringAfter("://") + .substringBefore('/') + .split('.') + .filter { it.isNotBlank() } + + return if (labels.size >= 2) labels.takeLast(2).joinToString(".") else null + } +} diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt index f211964ce..fee11e09f 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.autofill.domain.usecase -import de.davis.keygo.core.feature.autofill.FakeRegistrableDomainResolver import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId @@ -9,6 +8,7 @@ import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt index 3f58d2f27..b5bebd81a 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.autofill.domain.usecase -import de.davis.keygo.core.feature.autofill.FakeRegistrableDomainResolver import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId @@ -9,6 +8,7 @@ import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index fb8038195..a99c042be 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -3,7 +3,6 @@ package de.davis.keygo.feature.autofill.presentation.activity import androidx.lifecycle.SavedStateHandle import de.davis.keygo.core.feature.autofill.FakeAutofillDatasetProvider import de.davis.keygo.core.feature.autofill.FakeDigitalAssetLinkRepository -import de.davis.keygo.core.feature.autofill.FakeRegistrableDomainResolver import de.davis.keygo.core.feature.autofill.FakeSignatureInfoProvider import de.davis.keygo.core.feature.autofill.FakeTotpGenerator import de.davis.keygo.core.feature.autofill.FakeTotpRepository @@ -21,6 +20,7 @@ import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.Result import de.davis.keygo.feature.autofill.domain.usecase.AddRegistrableDomainsToLoginUseCase import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt index d0247ccd8..ab377c7fc 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.autofill.presentation.dataset -import de.davis.keygo.core.feature.autofill.FakeRegistrableDomainResolver import de.davis.keygo.core.feature.autofill.autofillId import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.domain.alias.newItemId @@ -15,6 +14,7 @@ import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.feature.autofill.presentation.model.FieldType import de.davis.keygo.feature.autofill.presentation.model.Form import de.davis.keygo.feature.autofill.presentation.model.FormField diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeRegistrableDomainResolver.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeRegistrableDomainResolver.kt deleted file mode 100644 index 2ce212a66..000000000 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeRegistrableDomainResolver.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.davis.keygo.core.feature.autofill - -import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver - -/** - * In-memory [RegistrableDomainResolver] for tests. - * - * Set [resolutions] to configure which domains resolve to which eTLD1 value. - * Inspect [resolvedDomains] to verify which domains were resolved. - */ -class FakeRegistrableDomainResolver : RegistrableDomainResolver { - // Configurable: map from domain to resolved eTLD1 (or null) - var resolutions: Map = emptyMap() - - // Track calls for assertion - val resolvedDomains: MutableList = mutableListOf() - - override fun resolve(domain: String): String? { - resolvedDomains += domain - return resolutions[domain] - } -} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt index 710f823ed..8e2332529 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt @@ -8,9 +8,22 @@ sealed interface DetailPaneInformation { sealed interface Init : DetailPaneInformation { val itemType: VaultItemType - data class New(override val itemType: VaultItemType) : Init - data class Existing(override val itemType: VaultItemType, val id: ItemId) : Init - data class TOTP(override val itemType: VaultItemType, val uri: String) : Init + /** + * A scanned code the form should fold in once it is built. Null for an ordinary create or + * edit. + */ + val pendingTotpUri: String? + + data class New( + override val itemType: VaultItemType, + override val pendingTotpUri: String? = null, + ) : Init + + data class Existing( + override val itemType: VaultItemType, + val id: ItemId, + override val pendingTotpUri: String? = null, + ) : Init } @Serializable diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 4f128a744..57a45e494 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -11,13 +12,22 @@ android { } dependencies { + implementation(libs.androidx.navigation.compose) + implementation(projects.core.ui) implementation(projects.core.item) implementation(projects.core.security) implementation(projects.feature.item.core) + implementation(projects.feature.listScreen) implementation(projects.feature.totp) implementation(projects.feature.creditCard) implementation(libs.offrange.passgen) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.core.util)) + testImplementation(testFixtures(projects.rust)) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/OverrideTotpDialog.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/OverrideTotpDialog.kt index 76f4d0e14..f1a642c17 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/OverrideTotpDialog.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/OverrideTotpDialog.kt @@ -123,7 +123,7 @@ fun OverrideTotpDialog( @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun SelectItemForTotpModificationDialogPreview() { +private fun OverrideTotpDialogPreview() { KeyGoTheme { Surface(modifier = Modifier.fillMaxSize()) { OverrideTotpDialog( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt deleted file mode 100644 index bd580a661..000000000 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt +++ /dev/null @@ -1,162 +0,0 @@ -package de.davis.keygo.feature.item.create.presentation.component - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import de.davis.keygo.core.item.domain.alias.newItemId -import de.davis.keygo.core.item.domain.model.DomainInfo -import de.davis.keygo.core.item.domain.model.lite.LiteLogin -import de.davis.keygo.core.ui.theme.KeyGoTheme -import de.davis.keygo.feature.item.create.R - -@Composable -fun SelectItemForTotpModificationDialog( - onDismissRequest: () -> Unit, - items: List, - onItemClicked: (LiteLogin) -> Unit, - onCreateNew: () -> Unit, - modifier: Modifier = Modifier -) { - AlertDialog( - onDismissRequest = onDismissRequest, - modifier = modifier, - confirmButton = { - OutlinedButton( - onClick = onCreateNew - ) { - Text(text = stringResource(R.string.create_new)) - } - }, - icon = { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = null - ) - }, - title = { - Text(text = stringResource(R.string.existing_entries_found)) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = stringResource(R.string.existing_entries_found_description)) - HorizontalDivider() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(items = items, key = { it.id }) { item -> - OutlinedCard( - onClick = { - onItemClicked(item) - }, - colors = CardDefaults.outlinedCardColors( - containerColor = Color.Transparent - ) - ) { - MultiSupportingLineItem( - headlineContent = { - Text(text = item.name) - }, - supportingContent = { - item.username?.let { - Text(text = stringResource(R.string.list_entry, it)) - } - - Text( - text = buildAnnotatedString { - item.domains.take(3).joinToString { it.value }.also { - append(stringResource(R.string.list_entry, it)) - } - - if (item.domains.size <= 3) - return@buildAnnotatedString - - append(", ") - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append( - stringResource( - R.string.n_more, - item.domains.size - 3 - ) - ) - } - } - ) - } - ) - } - } - } - } - } - ) -} - -@Preview -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SelectItemForTotpModificationDialogPreview() { - KeyGoTheme { - Surface(modifier = Modifier.fillMaxSize()) { - SelectItemForTotpModificationDialog( - onDismissRequest = {}, - onItemClicked = {}, - onCreateNew = {}, - items = listOf( - LiteLogin( - id = newItemId(), - name = "${if (1 >= 5) 'A' else 'B'} Item 1", - username = "User 1", - domains = listOf( - DomainInfo( - loginId = newItemId(), - value = "Website", - eTLD1 = "website.com" - ) - ), - pinned = false, - hasPassword = true, - ), - LiteLogin( - id = newItemId(), - name = "${if (2 >= 5) 'A' else 'B'} Item 2", - username = "User 2", - domains = listOf( - DomainInfo( - loginId = newItemId(), - value = "Website", - eTLD1 = "website.com" - ) - ), - pinned = false, - hasPassword = true, - ) - ) - ) - } - } -} \ No newline at end of file diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index acb4c970f..1b84f2202 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -77,7 +77,6 @@ internal class CreditCardViewModel( viewModelScope.launch { initWithId(information.id) } is DetailPaneInformation.Init.New, - is DetailPaneInformation.Init.TOTP, is DetailPaneInformation.CreateRaw -> Unit // nothing to prefill } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index ba3c5cecf..8547f4fa9 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -59,7 +59,6 @@ import de.davis.keygo.feature.item.create.presentation.component.FormGroup import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm import de.davis.keygo.feature.item.create.presentation.component.OverrideTotpDialog -import de.davis.keygo.feature.item.create.presentation.component.SelectItemForTotpModificationDialog import de.davis.keygo.feature.item.create.presentation.component.TAG_DELIMITERS import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState @@ -77,7 +76,10 @@ import de.davis.keygo.core.item.R as CoreItemR import de.davis.keygo.feature.item.core.R as ItemCoreR @Composable -internal fun LoginContent(state: LoginUiState, onEvent: (LoginUiEvent) -> Unit) { +internal fun LoginContent( + state: LoginUiState, + onEvent: (LoginUiEvent) -> Unit, +) { ItemContentWrapper( itemType = VaultItemType.Login, state = state, @@ -296,20 +298,6 @@ private fun LoginReadyContent( ) } - is DialogState.SelectItemForModification -> { - SelectItemForTotpModificationDialog( - onDismissRequest = { - // Don't allow dismissal - }, - items = state.dialogState.items, - onItemClicked = { item -> - onEvent(LoginUiEvent.OnTotpModificationItemSelected(item.id)) - }, - onCreateNew = { onEvent(LoginUiEvent.OnCreateNewItemForTotp) }, - modifier = Modifier.fillMaxWidth(), - ) - } - is DialogState.OverrideTotp -> { OverrideTotpDialog( onDismissRequest = { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index 738fb0d20..d78bfa03a 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -15,7 +15,6 @@ import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt -import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver @@ -27,7 +26,6 @@ import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.fieldUpdate -import de.davis.keygo.feature.item.core.domain.model.resolveTotpDomain import de.davis.keygo.feature.item.core.domain.model.set import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase import de.davis.keygo.feature.item.core.domain.usecase.ValidateTotpInputUseCase @@ -42,6 +40,7 @@ import de.davis.keygo.feature.item.create.presentation.login.model.LoginPasskeyI import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.OverrideTotpField import de.davis.keygo.feature.item.create.presentation.model.ItemUiState +import de.davis.keygo.feature.totp.domain.model.resolveTotpDomain import de.davis.keygo.rust.totp.TotpService import de.davis.keygo.rust.totp.getInfoFromUriWithResult import de.davis.keygo.rust.totp.getUrlWithResult @@ -71,7 +70,6 @@ internal class LoginViewModel( private val passwordStrengthEstimator: PasswordStrengthEstimator, private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, private val validateTotpInput: ValidateTotpInputUseCase, - private val getTdlMatchedLogins: GetTdlMatchedLoginsUseCase, private val snackbarManager: SnackbarManager, private val totpService: TotpService, private val registrableDomainResolver: RegistrableDomainResolver, @@ -110,9 +108,6 @@ internal class LoginViewModel( base.copy(strengthScore = score) } - private var totpSecretInformation: TotpInfo? = null - private var totpOriginalUri: String? = null - /** * Shows a passkey for [rp] as pending until the item is saved. * @@ -130,9 +125,13 @@ internal class LoginViewModel( fun init(information: DetailPaneInformation) { when (information) { - is DetailPaneInformation.Init.Existing -> viewModelScope.launch { initWithId(information.id) } - is DetailPaneInformation.Init.TOTP -> initWithTotpUri(information.uri) - is DetailPaneInformation.Init.New -> {} // Don't init anything + is DetailPaneInformation.Init.Existing -> viewModelScope.launch { + initWithId(information.id, information.pendingTotpUri) + } + + is DetailPaneInformation.Init.New -> information.pendingTotpUri?.let { uri -> + parsePendingTotp(uri)?.let { updateUiWithTotpSecretInfo(it, uri) } + } is DetailPaneInformation.CreateRaw -> initWithRawItem(information) } @@ -163,7 +162,12 @@ internal class LoginViewModel( } } - private suspend fun initWithId(itemId: ItemId) { + /** + * @param pendingTotpUri a code the picker handed over, folded in once the item it belongs to is + * on screen. It is parsed here rather than before the load, so nothing has to be carried across + * the two. + */ + private suspend fun initWithId(itemId: ItemId, pendingTotpUri: String?) { this.itemId = itemId itemWithCryptoScope.oneShot( @@ -215,39 +219,22 @@ internal class LoginViewModel( ) } - totpSecretInformation?.let { - requestTotpSecretUpdate(it, totpOriginalUri) + pendingTotpUri?.let { uri -> + parsePendingTotp(uri)?.let { requestTotpSecretUpdate(it, uri) } } } } - private fun initWithTotpUri(totpUri: String) { - totpService.getInfoFromUriWithResult(totpUri).onFailure { - Log.e(TAG, "Error parsing TOTP URI: $it") - showTotpParseError() - }.onSuccess { secret -> - totpSecretInformation = secret - totpOriginalUri = totpUri - viewModelScope.launch { - val matchedItems = secret.issuer?.let { - getTdlMatchedLogins(it) - } - - if (matchedItems.isNullOrEmpty()) { - updateUiWithTotpSecretInfo(secret, totpUri) - return@launch - } - - _base.update { - it.copy( - dialogState = DialogState.SelectItemForModification( - items = matchedItems, - ) - ) - } - } - } - } + /** + * Reads a code the picker handed over. Returns null when the code cannot be read, which the + * redirect that starts the import already ruled out, so there is nothing to tell the user about + * here. This function assumes its caller already validated the uri, so a new caller passing + * `pendingTotpUri` needs its own parse gate upstream. + */ + private fun parsePendingTotp(uri: String): TotpInfo? = + totpService.getInfoFromUriWithResult(uri).onFailure { failure -> + Log.e(TAG, "Error parsing TOTP URI: $failure") + }.getOrNull() override fun onSubmit() { val ready = state.value as? ItemUiState.Ready ?: return @@ -356,30 +343,16 @@ internal class LoginViewModel( is LoginUiEvent.OnCodesScanned -> { event.codes.firstNotNullOfOrNull { code -> - totpService.getInfoFromUriWithResult(code).onFailure { failure -> - Log.e(TAG, "Error parsing TOTP URI: $failure") - }.getOrNull()?.let { code to it } + parsePendingTotp(code)?.let { code to it } }?.let { (scannedUri, secretInfo) -> _base.update { state -> state.copy(scanning = false) } - totpOriginalUri = scannedUri - totpSecretInformation = secretInfo requestTotpSecretUpdate(secretInfo, scannedUri) } ?: showTotpParseError() } - is LoginUiEvent.OnTotpModificationItemSelected -> { - viewModelScope.launch { initWithId(event.itemId) } - } - - is LoginUiEvent.OnCreateNewItemForTotp -> { - totpSecretInformation?.let { - updateUiWithTotpSecretInfo(it, totpOriginalUri) - } - } - is LoginUiEvent.OnOverrideFieldClicked -> { _base.update { it.copy( @@ -403,21 +376,16 @@ internal class LoginViewModel( val currentDialogState = _base.value.dialogState if (currentDialogState !is DialogState.OverrideTotp) return - totpSecretInformation?.let { - val selectedFields = - currentDialogState.fields.filter { field -> field.selected } - - selectedFields.applyToUi { after } - } + currentDialogState.fields + .filter { field -> field.selected } + .applyToUi { after } } is LoginUiEvent.OnOverrideTotpFieldsKept -> { val currentDialogState = _base.value.dialogState if (currentDialogState !is DialogState.OverrideTotp) return - totpSecretInformation?.let { - currentDialogState.fields.applyToUi { before } - } + currentDialogState.fields.applyToUi { before } } is LoginUiEvent.OnTotpParseErrorDismiss -> { @@ -500,14 +468,14 @@ internal class LoginViewModel( private fun requestTotpSecretUpdate( secretInformation: TotpInfo, - originalUri: String? = null, + originalUri: String, ) { val currentState = _base.value val currentTotpSecret = currentState.totpTextFieldState.text.toString() val currentIssuers = currentState.domains val currentAccountName = currentState.usernameTextFieldState.text.toString() - val newTotpSecret = originalUri ?: secretInformation.secret + val newTotpSecret = originalUri val newDomain = resolveTotpDomain(secretInformation.issuer, secretInformation.accountName) val newAccountName = secretInformation.accountName @@ -559,9 +527,9 @@ internal class LoginViewModel( private fun updateUiWithTotpSecretInfo( secretInformation: TotpInfo, - originalUri: String? = null, + originalUri: String, ) = updateUiWithSpecificTotpSecretInfo( - secret = originalUri ?: secretInformation.secret, + secret = originalUri, issuer = resolveTotpDomain(secretInformation.issuer, secretInformation.accountName), accountName = secretInformation.accountName, ) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt index 2c008e25e..2819dffa7 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt @@ -1,12 +1,10 @@ package de.davis.keygo.feature.item.create.presentation.login.model import de.davis.keygo.core.item.domain.model.PasskeyRef -import de.davis.keygo.core.item.domain.model.lite.LiteLogin sealed interface DialogState { data object None : DialogState data object TotpParseError : DialogState - data class SelectItemForModification(val items: List) : DialogState data class OverrideTotp(val fields: Set) : DialogState data class DeletePasskey(val passkey: PasskeyRef) : DialogState diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt index 9e868b8ed..bae406b35 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.item.create.presentation.login.model -import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.feature.item.core.presentation.login.model.FieldType import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent @@ -22,8 +21,6 @@ internal sealed interface LoginUiEvent { data object OnTotpParseErrorDismiss : LoginUiEvent data class OnCodesScanned(val codes: List) : LoginUiEvent - data class OnTotpModificationItemSelected(val itemId: ItemId) : LoginUiEvent - data object OnCreateNewItemForTotp : LoginUiEvent data class OnOverrideFieldClicked(val fieldType: FieldType) : LoginUiEvent data object OnOverrideTotpFieldsConfirmed : LoginUiEvent 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/AssignTotpGraph.kt new file mode 100644 index 000000000..a58270bc9 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/totp/AssignTotpGraph.kt @@ -0,0 +1,44 @@ +package de.davis.keygo.feature.item.create.presentation.totp + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +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 +import java.util.UUID + +@Serializable +data class AssignTotpRoute( + val totpUri: String, + val itemId: String? = null, +) : RouteDestination { + val selectedItemId: ItemId? + get() = itemId?.let(UUID::fromString) +} + +fun NavGraphBuilder.assignTotpGraph( + onImportFinished: () -> Unit, + navigateUp: () -> Unit, +) { + composable { entry -> + val route = entry.toRoute() + LoginScreen( + detailPaneInformation = route.selectedItemId?.let { itemId -> + DetailPaneInformation.Init.Existing( + itemType = VaultItemType.Login, + id = itemId, + pendingTotpUri = route.totpUri, + ) + } ?: DetailPaneInformation.Init.New( + itemType = VaultItemType.Login, + pendingTotpUri = route.totpUri, + ), + loginCreated = { onImportFinished() }, + navigateBack = navigateUp, + ) + } +} diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 819cf1e82..0ede2902d 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -17,14 +17,10 @@ Keep Select an Item - Create New Length [%d] Character Sets - Existing Entries found - One or more entries match the issuer or account name from the totp code. Would you like to update an existing entry or create a new one? - Warning Cancel @@ -48,9 +44,6 @@ Enter Name Write your Note - %d more - - \u2022 %s \u2022 Before: %s \u2022 After: %s diff --git a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt new file mode 100644 index 000000000..b51d9775e --- /dev/null +++ b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/login/LoginViewModelTest.kt @@ -0,0 +1,320 @@ +package de.davis.keygo.feature.item.create.presentation.login + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasswordStrengthEstimator +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.FakeRegistrableDomainResolver +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.domain.snackbar.SnackbarManager +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.item.core.domain.usecase.ValidateTotpInputUseCase +import de.davis.keygo.feature.item.core.presentation.login.model.FieldType +import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.create.presentation.login.model.DialogState +import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState +import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState +import de.davis.keygo.rust.FakeTotpService +import de.davisalessandro.keygo.rust.Algorithm +import de.davisalessandro.keygo.rust.TotpInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Covers what the login form does with a code the picker handed it: a new item is prefilled from + * the code, and a chosen item has it folded in, raising the override dialog when the two collide. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LoginViewModelTest { + + private val mainDispatcher = UnconfinedTestDispatcher() + + private val defaultVault = Vault( + id = newVaultId(), + name = "Default vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val cryptoProvider = FakeCryptographicScopeProvider(itemRepository) + private val totpService = FakeTotpService() + private val domainResolver = FakeRegistrableDomainResolver() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + vaultRepository.seed(defaultVault) + vaultContextRepository.seedLastInteracted(defaultVault.id) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `back leaves the screen when the code was scanned into an open form`() = runVmTest { + totpService.infoFromUriResult = totpInfo(issuer = "github.com") + val viewModel = buildViewModel() + backgroundScope.launch(mainDispatcher) { viewModel.state.collect { } } + viewModel.init(DetailPaneInformation.Init.New(VaultItemType.Login)) + advanceUntilIdle() + val navigation = collectNavigation(viewModel) + viewModel.onEvent(LoginUiEvent.OnCodesScanned(listOf(DEEP_LINK_URI))) + advanceUntilIdle() + + viewModel.onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) + advanceUntilIdle() + + assertEquals(listOf(null), navigation) + } + + @Test + fun `a new item is prefilled from the picker's code`() = runVmTest { + totpService.infoFromUriResult = totpInfo(issuer = "github.com") + + val viewModel = buildViewModel() + backgroundScope.launch(mainDispatcher) { viewModel.state.collect { } } + viewModel.init( + DetailPaneInformation.Init.New( + itemType = VaultItemType.Login, + pendingTotpUri = DEEP_LINK_URI, + ), + ) + advanceUntilIdle() + + val base = viewModel.readyBase() + assertEquals(DEEP_LINK_URI, base.totpTextFieldState.text.toString()) + assertEquals("me@github.com", base.usernameTextFieldState.text.toString()) + assertEquals(setOf("github.com"), base.domains.mapTo(mutableSetOf()) { it.value }) + } + + @Test + fun `an existing item is loaded and the picker's code folded in`() = runVmTest { + val existing = seedLogin(name = "GitHub", domain = "github.com") + totpService.infoFromUriResult = totpInfo(issuer = "github.com") + + val viewModel = buildViewModel() + backgroundScope.launch(mainDispatcher) { viewModel.state.collect { } } + viewModel.init( + DetailPaneInformation.Init.Existing( + itemType = VaultItemType.Login, + id = existing, + pendingTotpUri = DEEP_LINK_URI, + ), + ) + advanceUntilIdle() + + val state = viewModel.readyState() + assertTrue(state.base.updating) + assertEquals("GitHub", state.shared.nameTextFieldState.text.toString()) + assertEquals(DEEP_LINK_URI, state.base.totpTextFieldState.text.toString()) + assertEquals("me@github.com", state.base.usernameTextFieldState.text.toString()) + } + + @Test + fun `a code that collides with the chosen item raises the override dialog`() = runVmTest { + val viewModel = viewModelOnOverrideDialog() + + val dialog = viewModel.readyBase().dialogState + assertIs(dialog) + val usernameField = dialog.fields.single { it.fieldType == FieldType.Username } + assertEquals("old@github.com", usernameField.before) + assertEquals("me@github.com", usernameField.after) + } + + @Test + fun `confirming the override writes the code's fields into the form`() = runVmTest { + val viewModel = viewModelOnOverrideDialog() + + viewModel.onEvent(LoginUiEvent.OnOverrideTotpFieldsConfirmed) + advanceUntilIdle() + + val base = viewModel.readyBase() + assertEquals("me@github.com", base.usernameTextFieldState.text.toString()) + assertEquals(DialogState.None, base.dialogState) + } + + @Test + fun `keeping the current fields leaves the form as the item had it`() = runVmTest { + val viewModel = viewModelOnOverrideDialog() + + viewModel.onEvent(LoginUiEvent.OnOverrideTotpFieldsKept) + advanceUntilIdle() + + val base = viewModel.readyBase() + assertEquals("old@github.com", base.usernameTextFieldState.text.toString()) + assertEquals(DialogState.None, base.dialogState) + } + + // Helpers + + /** + * A form on an existing login whose username the scanned code disagrees with, left sitting on + * the override dialog that disagreement raises. Every field arrives selected, so a confirm + * straight after this takes the code's side of all of them. + */ + private fun TestScope.viewModelOnOverrideDialog(): LoginViewModel { + val existing = seedLogin( + name = "GitHub", + domain = "github.com", + username = "old@github.com", + ) + totpService.infoFromUriResult = totpInfo(issuer = "github.com") + + val viewModel = buildViewModel() + backgroundScope.launch(mainDispatcher) { viewModel.state.collect { } } + viewModel.init( + DetailPaneInformation.Init.Existing( + itemType = VaultItemType.Login, + id = existing, + pendingTotpUri = DEEP_LINK_URI, + ), + ) + advanceUntilIdle() + + return viewModel + } + + private fun runVmTest(body: suspend TestScope.() -> Unit) = + runTest(mainDispatcher.scheduler) { body() } + + /** + * Records what the screen asks navigation to do. Leaving raises `null`, a saved item raises its + * id, and an empty list is the assertion that the screen stayed where it was. + */ + private fun TestScope.collectNavigation(viewModel: LoginViewModel): List { + val events = mutableListOf() + backgroundScope.launch(mainDispatcher) { viewModel.itemCreatedEvent.collect { events += it } } + return events + } + + private fun LoginViewModel.readyState(): ItemUiState.Ready { + val state = state.value + assertIs>(state) + return state + } + + private fun LoginViewModel.readyBase(): LoginBaseState = readyState().base + + private fun seedLogin( + name: String, + domain: String, + username: String? = null, + ): ItemId { + val id = newItemId() + loginRepository.seed( + Login( + id = id, + name = name, + username = username, + domainInfos = setOf( + DomainInfo( + loginId = id, + value = domain, + eTLD1 = domainResolver.resolve(domain), + ), + ), + passwordCredential = null, + totp = null, + passkeys = emptySet(), + note = null, + pinned = false, + vaultId = defaultVault.id, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + ), + ) + return id + } + + private fun totpInfo( + issuer: String?, + accountName: String = "me@github.com", + secret: String = "JBSWY3DPEHPK3PXP", + ) = TotpInfo( + secret = secret, + issuer = issuer, + accountName = accountName, + algorithm = Algorithm.SHA1, + digits = 6, + period = 30, + ) + + private fun buildViewModel() = LoginViewModel( + itemWithCryptoScope = ItemWithCryptoScopeUseCase(vaultRepository, cryptoProvider), + loginRepository = loginRepository, + passwordStrengthEstimator = FakePasswordStrengthEstimator(), + createNewOrUpdateLogin = CreateNewOrUpdateLoginUseCase( + cryptographicScopeProvider = cryptoProvider, + loginRepository = loginRepository, + vaultRepository = vaultRepository, + upsertVaultItem = UpsertVaultItemUseCase( + loginRepository, + FakeCreditCardRepository(), + ), + passwordStrengthEstimator = FakePasswordStrengthEstimator(), + totpService = totpService, + ), + snackbarManager = TestSnackbarManager(), + totpService = totpService, + registrableDomainResolver = domainResolver, + vaultContextRepository = vaultContextRepository, + itemRepository = itemRepository, + observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, SortUseCase()), + vaultRepository = vaultRepository, + validateTotpInput = ValidateTotpInputUseCase(totpService) + ) + + private class TestSnackbarManager : SnackbarManager { + override val oneShotEvents: Flow = emptyFlow() + override fun sendMessage(message: SnackbarMessage) = Unit + } + + companion object { + private const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} 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 36486d222..5e5a77f43 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier @@ -38,6 +39,7 @@ fun ItemListScreen( onItemsDelete: (deleted: Set, firstItemId: ItemId?) -> Unit = { _, _ -> }, restrictedItemType: VaultItemType? = null, notFoundStrategy: NoItemStrategy = NoItemStrategy.ShowCreateNewItemCard, + suggestedItemIds: Set = emptySet(), autoSelectFirst: Boolean = false, enableDeletion: Boolean = true, enableSelection: Boolean = true, @@ -47,9 +49,16 @@ fun ItemListScreen( val viewModel = koinViewModel { parametersOf(enableSelection, restrictedItemType) } - val uiState by viewModel.listItemState.collectAsStateWithLifecycle() + val collectedState by viewModel.listItemState.collectAsStateWithLifecycle() val filterSheetState by viewModel.filterBottomSheetState.collectAsStateWithLifecycle() + // Suggestions rank the default list only. Once the user searches, the results carry their own + // relevance and a group pinned above them would fight it. + val suggested = if (collectedState.hasSearchQuery) emptySet() else suggestedItemIds + val uiState = remember(collectedState, suggested) { + collectedState.copy(items = collectedState.items.withSuggestedFirst(suggested)) + } + LaunchedEffect(autoSelectFirst) { if (!autoSelectFirst) viewModel.resetHighlight() } @@ -101,6 +110,7 @@ fun ItemListScreen( autoSelectFirst = autoSelectFirst, notFoundStrategy = notFoundStrategy, restrictedItemType = restrictedItemType, + suggestedItemIds = suggested, onCreateItemRequest = onCreateItemRequest, onSubmitQuery = viewModel::onSubmitQuery, onClearQuery = viewModel::onClearQuery, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItems.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItems.kt new file mode 100644 index 000000000..b9adbffe2 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItems.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.list_screen.presentation + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.lite.LiteItem + +internal fun List.withSuggestedFirst(suggestedItemIds: Set): List { + if (suggestedItemIds.isEmpty()) return this + + val (suggested, rest) = partition { it.id in suggestedItemIds } + return if (suggested.isEmpty()) this else suggested + rest +} 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 2252e9721..3b5b41b25 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 @@ -68,6 +68,7 @@ internal fun ItemListContent( autoSelectFirst: Boolean, notFoundStrategy: NoItemStrategy, restrictedItemType: VaultItemType?, + suggestedItemIds: Set, onCreateItemRequest: (VaultItemType) -> Unit, onSubmitQuery: () -> Unit, onClearQuery: () -> Unit, @@ -217,11 +218,14 @@ internal fun ItemListContent( } false -> { - val items = remember(uiState.items) { + val items = remember(uiState.items, suggestedItemIds) { uiState.items.map { KeyGoColumnItem( - header = if (it.pinned) HeaderContent.Pin - else HeaderContent.Letter(it.name.first().uppercaseChar()), + header = when { + it.id in suggestedItemIds -> HeaderContent.Suggested + it.pinned -> HeaderContent.Pin + else -> HeaderContent.Letter(it.name.first().uppercaseChar()) + }, title = it.name, id = it.id, itemType = it.itemType, @@ -300,6 +304,7 @@ private fun ItemListContentPreview() { autoSelectFirst = false, notFoundStrategy = NoItemStrategy.ShowCreateNewItemCard, restrictedItemType = null, + suggestedItemIds = emptySet(), onCreateItemRequest = {}, onSubmitQuery = {}, onClearQuery = {}, diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItemsTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItemsTest.kt new file mode 100644 index 000000000..fa5be35a5 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/SuggestedItemsTest.kt @@ -0,0 +1,80 @@ +package de.davis.keygo.feature.list_screen.presentation + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class SuggestedItemsTest { + + private data class TestLiteItem( + override val name: String, + override val id: ItemId = UUID.nameUUIDFromBytes(name.toByteArray()), + override val itemType: VaultItemType = VaultItemType.Login, + override val pinned: Boolean = false, + ) : LiteItem + + private fun items(vararg names: String) = names.map { TestLiteItem(name = it) } + + private fun idsOf(vararg names: String) = names.mapTo(mutableSetOf()) { TestLiteItem(it).id } + + private fun List.names() = map { it.name } + + @Test + fun `no suggestions leaves the list untouched`() { + val provided = items("Amazon", "GitHub", "Google") + + val result = provided.withSuggestedFirst(emptySet()) + + assertSame(provided, result) + } + + @Test + fun `suggested items move to the front`() { + val provided = items("Amazon", "GitHub", "Google") + + val result = provided.withSuggestedFirst(idsOf("GitHub")) + + assertEquals(listOf("GitHub", "Amazon", "Google"), result.names()) + } + + @Test + fun `order within each group is preserved`() { + val provided = items("Amazon", "GitHub", "Google", "GitHub (work)") + + val result = provided.withSuggestedFirst(idsOf("GitHub", "GitHub (work)")) + + assertEquals( + listOf("GitHub", "GitHub (work)", "Amazon", "Google"), + result.names(), + ) + } + + @Test + fun `ids that are not in the list are ignored`() { + val provided = items("Amazon", "Google") + + val result = provided.withSuggestedFirst(idsOf("GitHub")) + + assertSame(provided, result) + } + + @Test + fun `a partially present suggestion set hoists only what the list holds`() { + val provided = items("Amazon", "Google") + + val result = provided.withSuggestedFirst(idsOf("GitHub", "Google")) + + assertEquals(listOf("Google", "Amazon"), result.names()) + } + + @Test + fun `an empty list stays empty`() { + val result = emptyList().withSuggestedFirst(idsOf("GitHub")) + + assertEquals(emptyList(), result) + } +} diff --git a/feature/totp/build.gradle.kts b/feature/totp/build.gradle.kts index afb6146ee..a03af0446 100644 --- a/feature/totp/build.gradle.kts +++ b/feature/totp/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -21,9 +22,13 @@ dependencies { implementation(libs.com.google.accompanist.permissions) implementation(projects.rust) + implementation(projects.core.ui) implementation(projects.core.security) implementation(projects.core.item) implementation(projects.core.util) + implementation(projects.feature.listScreen) + + implementation(libs.androidx.navigation.compose) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.compose) @@ -34,5 +39,8 @@ dependencies { testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.rust)) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomain.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomain.kt similarity index 83% rename from feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomain.kt rename to feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomain.kt index f6d6a97c0..024c831f9 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomain.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomain.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.item.core.domain.model +package de.davis.keygo.feature.totp.domain.model fun resolveTotpDomain(issuer: String?, accountName: String): String? { if (!issuer.isNullOrEmpty()) return issuer 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/SelectItemForTotpGraph.kt new file mode 100644 index 000000000..51babbd14 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpGraph.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.totp.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +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 + +fun NavGraphBuilder.selectItemForTotpGraph( + onItemSelected: (totpUri: String, itemId: ItemId) -> Unit, + onCreateNew: (totpUri: String) -> Unit, +) { + composable { entry -> + val route = entry.toRoute() + SelectItemForTotpScreen( + totpUri = route.totpUri, + onItemSelected = { itemId -> onItemSelected(route.totpUri, itemId) }, + onCreateNew = { onCreateNew(route.totpUri) }, + ) + } +} diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpScreen.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpScreen.kt new file mode 100644 index 000000000..07b48eb28 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpScreen.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.totp.presentation + +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.presentation.ItemListScreen +import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy +import de.davis.keygo.feature.totp.R +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun SelectItemForTotpScreen( + totpUri: String, + onItemSelected: (ItemId) -> Unit, + onCreateNew: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: SelectItemForTotpViewModel = koinViewModel { parametersOf(totpUri) } + val state by viewModel.state.collectAsStateWithLifecycle() + + SelectItemForTotpContent( + state = state, + onItemSelected = onItemSelected, + onCreateNew = onCreateNew, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectItemForTotpContent( + state: SelectItemForTotpUiState, + onItemSelected: (ItemId) -> Unit, + onCreateNew: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar(title = { Text(text = stringResource(R.string.select_item_for_totp)) }) + }, + floatingActionButton = { + FloatingActionButton(onClick = onCreateNew) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.create_new), + ) + } + }, + ) { innerPadding -> + ItemListScreen( + onItemClick = onItemSelected, + onItemLongClick = { }, + onCreateItemRequest = { onCreateNew() }, + restrictedItemType = VaultItemType.Login, + suggestedItemIds = state.suggestedItemIds, + notFoundStrategy = NoItemStrategy.ShowMessage, + enableDeletion = false, + enableSelection = false, + dockedSearchResults = false, + modifier = Modifier + .consumeWindowInsets(innerPadding) + .padding(innerPadding), + ) + } +} diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpUiState.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpUiState.kt new file mode 100644 index 000000000..660c22f06 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpUiState.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.totp.presentation + +import de.davis.keygo.core.item.domain.alias.ItemId + +internal data class SelectItemForTotpUiState( + val suggestedItemIds: Set = emptySet(), +) diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModel.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModel.kt new file mode 100644 index 000000000..5d0f5905a --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModel.kt @@ -0,0 +1,55 @@ +package de.davis.keygo.feature.totp.presentation + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.totp.domain.model.resolveTotpDomain +import de.davis.keygo.rust.totp.TotpService +import de.davis.keygo.rust.totp.getInfoFromUriWithResult +import de.davisalessandro.keygo.rust.TotpInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class SelectItemForTotpViewModel( + @InjectedParam private val totpUri: String, + private val totpService: TotpService, + private val getTdlMatchedLogins: GetTdlMatchedLoginsUseCase, +) : ViewModel() { + + private val _state = MutableStateFlow(SelectItemForTotpUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + totpService.getInfoFromUriWithResult(totpUri).onFailure { failure -> + Log.e(TAG, "Error parsing TOTP URI: $failure") + }.onSuccess { info -> + viewModelScope.launch { + val suggested = suggestedItemIdsFor(info) + _state.update { it.copy(suggestedItemIds = suggested) } + } + } + } + + private suspend fun suggestedItemIdsFor(info: TotpInfo): Set { + val domain = resolveTotpDomain( + issuer = info.issuer, + accountName = info.accountName, + ) ?: return emptySet() + + return getTdlMatchedLogins(domain).mapTo(mutableSetOf()) { it.id } + } + + companion object { + private const val TAG = "SelectItemForTotpVM" + } +} 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 new file mode 100644 index 000000000..655d66782 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirect.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.feature.totp.presentation + +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 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 + +@Serializable +data class TotpImportRedirect( + val totpInfo: String? = null, + val queries: String? = null, +) : RouteDestination { + val pendingImport: PendingTotpImport + get() = PendingTotpImport(totpInfo, queries) +} + +fun NavGraphBuilder.totpImportRedirectGraph( + onValidated: (PendingTotpImport) -> Unit, + onRejected: () -> Unit, +) { + composable( + deepLinks = listOf( + navDeepLink(basePath = PendingTotpImport.BASE_PATH) { + uriPattern = PendingTotpImport.URI_PATTERN + }, + ), + ) { entry -> + val route = entry.toRoute() + val viewModel: TotpImportRedirectViewModel = + koinViewModel { parametersOf(route.pendingImport) } + val state by viewModel.state.collectAsStateWithLifecycle() + + when (state) { + TotpImportRedirectState.Validating -> Unit + + TotpImportRedirectState.Valid -> LaunchedEffect(route) { + onValidated(route.pendingImport) + } + + TotpImportRedirectState.Invalid -> TotpParseErrorDialog( + onDismiss = onRejected, + modifier = Modifier.fillMaxWidth(), + onDismissRequest = onRejected, + ) + } + } +} 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 new file mode 100644 index 000000000..5570aa4f1 --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectState.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.totp.presentation + +internal sealed interface TotpImportRedirectState { + + data object Validating : TotpImportRedirectState + + data object Valid : 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 new file mode 100644 index 000000000..2d0c74b0d --- /dev/null +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModel.kt @@ -0,0 +1,47 @@ +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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class TotpImportRedirectViewModel( + @InjectedParam private val pendingImport: PendingTotpImport, + private val totpService: TotpService, +) : ViewModel() { + + private val _state = + MutableStateFlow(TotpImportRedirectState.Validating) + val state: StateFlow = _state.asStateFlow() + + init { + _state.value = validate() + } + + private fun validate(): TotpImportRedirectState { + val uri = pendingImport.uri ?: run { + Log.e(TAG, "Deep link carried no complete otpauth uri") + return TotpImportRedirectState.Invalid + } + + return totpService.getInfoFromUriWithResult(uri).fold( + onSuccess = { TotpImportRedirectState.Valid }, + onFailure = { failure -> + Log.e(TAG, "Error parsing TOTP URI: $failure") + TotpImportRedirectState.Invalid + }, + ) + } + + companion object { + private const val TAG = "TotpImportRedirectVM" + } +} diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/TotpParseErrorDialog.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/TotpParseErrorDialog.kt index 5a6d7b104..458579086 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/TotpParseErrorDialog.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/TotpParseErrorDialog.kt @@ -9,9 +9,13 @@ import androidx.compose.ui.res.stringResource import de.davis.keygo.feature.totp.R @Composable -fun TotpParseErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) { +fun TotpParseErrorDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit = {}, +) { AlertDialog( - onDismissRequest = {}, + onDismissRequest = onDismissRequest, confirmButton = { TextButton( onClick = onDismiss diff --git a/feature/totp/src/main/res/values/strings.xml b/feature/totp/src/main/res/values/strings.xml index d8e13a17d..57cb6710d 100644 --- a/feature/totp/src/main/res/values/strings.xml +++ b/feature/totp/src/main/res/values/strings.xml @@ -13,4 +13,7 @@ TOTP Parse Error The TOTP code could not be parsed. + + Add code to which item? + Create New \ No newline at end of file diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomainTest.kt b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomainTest.kt similarity index 96% rename from feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomainTest.kt rename to feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomainTest.kt index 5b491ff8a..c6dbbcbe7 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/model/TotpDomainTest.kt +++ b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/domain/model/TotpDomainTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.item.core.domain.model +package de.davis.keygo.feature.totp.domain.model import kotlin.test.Test import kotlin.test.assertEquals diff --git a/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModelTest.kt b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModelTest.kt new file mode 100644 index 000000000..e8facfd5d --- /dev/null +++ b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/SelectItemForTotpViewModelTest.kt @@ -0,0 +1,154 @@ +package de.davis.keygo.feature.totp.presentation + +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase +import de.davis.keygo.core.util.FakeRegistrableDomainResolver +import de.davis.keygo.rust.FakeTotpService +import de.davisalessandro.keygo.rust.Algorithm +import de.davisalessandro.keygo.rust.TotpInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SelectItemForTotpViewModelTest { + + private val mainDispatcher = UnconfinedTestDispatcher() + + private val vaultId = newVaultId() + private val loginRepository = FakeLoginRepository() + private val totpService = FakeTotpService() + private val domainResolver = FakeRegistrableDomainResolver() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `logins on the code's domain are suggested`() = runVmTest { + val onDomain = seedLogin(name = "GitHub", domain = "github.com") + seedLogin(name = "Google", domain = "google.com") + totpService.infoFromUriResult = totpInfo(issuer = "github.com") + + val viewModel = buildViewModel() + advanceUntilIdle() + + assertEquals(setOf(onDomain), viewModel.state.value.suggestedItemIds) + } + + @Test + fun `a code without an issuer suggests on the account name's domain`() = runVmTest { + val onDomain = seedLogin(name = "GitHub", domain = "github.com") + totpService.infoFromUriResult = totpInfo(issuer = null, accountName = "me@github.com") + + val viewModel = buildViewModel() + advanceUntilIdle() + + assertEquals(setOf(onDomain), viewModel.state.value.suggestedItemIds) + } + + @Test + fun `a code that matches no domain suggests nothing`() = runVmTest { + seedLogin(name = "Google", domain = "google.com") + totpService.infoFromUriResult = totpInfo(issuer = null, accountName = "no-domain-here") + + val viewModel = buildViewModel() + advanceUntilIdle() + + assertEquals(emptySet(), viewModel.state.value.suggestedItemIds) + } + + @Test + fun `an unreadable code leaves the picker with no suggestions`() = runVmTest { + seedLogin(name = "GitHub", domain = "github.com") + totpService.infoFromUriResult = null + + val viewModel = buildViewModel() + advanceUntilIdle() + + assertEquals(emptySet(), viewModel.state.value.suggestedItemIds) + } + + // Helpers + + private fun runVmTest(body: suspend TestScope.() -> Unit) = + runTest(mainDispatcher.scheduler) { body() } + + private fun buildViewModel(uri: String = DEEP_LINK_URI) = SelectItemForTotpViewModel( + totpUri = uri, + totpService = totpService, + getTdlMatchedLogins = GetTdlMatchedLoginsUseCase(domainResolver, loginRepository), + ) + + private fun seedLogin(name: String, domain: String): ItemId { + val id = newItemId() + loginRepository.seed( + Login( + id = id, + name = name, + username = null, + domainInfos = setOf( + DomainInfo( + loginId = id, + value = domain, + eTLD1 = domainResolver.resolve(domain), + ), + ), + passwordCredential = null, + totp = null, + passkeys = emptySet(), + note = null, + pinned = false, + vaultId = vaultId, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + ), + ) + return id + } + + private fun totpInfo( + issuer: String?, + accountName: String = "me@github.com", + secret: String = "JBSWY3DPEHPK3PXP", + ) = TotpInfo( + secret = secret, + issuer = issuer, + accountName = accountName, + algorithm = Algorithm.SHA1, + digits = 6, + period = 30, + ) + + companion object { + private const val DEEP_LINK_URI = + "otpauth://totp/GitHub:me@github.com?secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} 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 new file mode 100644 index 000000000..0eedbef65 --- /dev/null +++ b/feature/totp/src/test/kotlin/de/davis/keygo/feature/totp/presentation/TotpImportRedirectViewModelTest.kt @@ -0,0 +1,75 @@ +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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TotpImportRedirectViewModelTest { + + private val totpService = FakeTotpService() + + @Test + fun `a readable code is valid`() { + totpService.infoFromUriResult = totpInfo() + + val viewModel = buildViewModel(PendingTotpImport(TOTP_INFO, QUERIES)) + + assertEquals(TotpImportRedirectState.Valid, viewModel.state.value) + } + + @Test + fun `an unreadable code is invalid`() { + totpService.infoFromUriResult = null + + val viewModel = buildViewModel(PendingTotpImport(TOTP_INFO, QUERIES)) + + assertEquals(TotpImportRedirectState.Invalid, viewModel.state.value) + } + + @Test + fun `a link with no query string 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)) + + assertEquals(TotpImportRedirectState.Invalid, viewModel.state.value) + } + + // Helpers + + private fun buildViewModel(pendingImport: PendingTotpImport) = TotpImportRedirectViewModel( + pendingImport = pendingImport, + totpService = totpService, + ) + + private fun totpInfo() = TotpInfo( + secret = "JBSWY3DPEHPK3PXP", + issuer = "github.com", + accountName = "me@github.com", + algorithm = Algorithm.SHA1, + digits = 6, + period = 30, + ) + + companion object { + private const val TOTP_INFO = "GitHub:me@github.com" + private const val QUERIES = "secret=JBSWY3DPEHPK3PXP&issuer=github.com" + } +} diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt index 733928797..0eba3384b 100644 --- a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationEndToEndTest.kt @@ -20,11 +20,11 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.legacy_migration.data.FakeLegacyDatabaseProvider import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository -import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver import de.davis.keygo.legacy_migration.data.crypto.LegacyAesGcmCipher import de.davis.keygo.legacy_migration.data.encryptLikeV1 import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt index 059fb7c73..d2721327a 100644 --- a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/LegacyMigrationRealDatabaseTest.kt @@ -17,10 +17,10 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.legacy_migration.data.FakeLegacyDatabaseProvider import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository -import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver import de.davis.keygo.legacy_migration.data.crypto.LegacyAesGcmCipher import de.davis.keygo.legacy_migration.data.json.LegacyDetailParser import de.davis.keygo.legacy_migration.data.local.datasource.LegacyDatabase diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt index 54ea8c916..b53da349c 100644 --- a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverterTest.kt @@ -11,10 +11,10 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.legacy_migration.data.FAKE_LEGACY_KEY import de.davis.keygo.legacy_migration.data.FakeLegacyCipher -import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher import de.davis.keygo.legacy_migration.domain.model.LegacyDetail import de.davis.keygo.legacy_migration.domain.model.LegacyItem diff --git a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt index 6dbc73b27..dc3b265cc 100644 --- a/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt +++ b/legacy-migration/src/test/kotlin/de/davis/keygo/legacy_migration/domain/usecase/MigrateLegacyDataUseCaseTest.kt @@ -14,12 +14,12 @@ import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.FakeRegistrableDomainResolver import de.davis.keygo.core.util.Result import de.davis.keygo.legacy_migration.data.FAKE_LEGACY_KEY import de.davis.keygo.legacy_migration.data.FakeLegacyCipher import de.davis.keygo.legacy_migration.data.FakeLegacyItemRepository import de.davis.keygo.legacy_migration.data.FakeLegacyKeyRepository -import de.davis.keygo.legacy_migration.data.FakeRegistrableDomainResolver import de.davis.keygo.legacy_migration.domain.crypto.LegacyCipher import de.davis.keygo.legacy_migration.domain.mapper.LegacyItemConverter import de.davis.keygo.legacy_migration.domain.model.LegacyDetail diff --git a/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt b/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt deleted file mode 100644 index 7bf837b87..000000000 --- a/legacy-migration/src/testFixtures/kotlin/de/davis/keygo/legacy_migration/data/FakeRegistrableDomainResolver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.davis.keygo.legacy_migration.data - -import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver - -/** - * Resolves anything under `example` to `example.com` and everything else to null. - * - * Every origin the migration tests seed is an example.com URL, so a fixed answer keeps eTLD+1 - * resolution from becoming a second thing that can fail in a test about the import. The null branch - * is still real: it is what a host the resolver cannot place answers with, and the converter has to - * carry that through as a domain info without an eTLD+1 rather than dropping the origin. - */ -internal class FakeRegistrableDomainResolver : RegistrableDomainResolver { - - override fun resolve(domain: String): String? = - if (domain.contains("example")) "example.com" else null -}