diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 23f76da42..2aafa5061 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,6 +6,11 @@ on: pull_request: branches: [ "v2" ] +concurrency: + # Groups runs by workflow name and the branch/PR number. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.gitignore b/.gitignore index 7616e4628..23ac73ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ google-services.json docs/superpowers/ .superpowers/ .claude/ +.worktrees/ graphify-out/ .gitattributes \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e8648d4d..ffb70bed1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,9 +118,11 @@ dependencies { implementation(projects.feature.autofill) implementation(projects.feature.settings) implementation(projects.feature.backup) + implementation(projects.feature.onboarding) implementation(projects.migration.createAccess) implementation(projects.migration.legacyData) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(platform(libs.androidx.compose.bom)) @@ -134,6 +136,10 @@ dependencies { implementation(libs.androidx.navigation.compose) testImplementation(libs.kotlin.test) + testImplementation(libs.androidx.navigation.testing) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt index 218a6e699..3ad2db030 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt @@ -1,6 +1,7 @@ package de.davis.keygo.app.di import de.davis.keygo.dashboard.di.DashboardModule +import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module @@ -9,6 +10,7 @@ import org.koin.core.annotation.Module DashboardModule::class, ] ) +@ComponentScan("de.davis.keygo.app") @Configuration object AppModule diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt new file mode 100644 index 000000000..add7fd8e3 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/AppViewModel.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.app.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class AppViewModel( + private val accountRepository: AccountRepository, + private val hasV1Password: HasMainPasswordUseCase, +) : ViewModel() { + + private val _isReturningUser = MutableStateFlow(null) + val isReturningUser = _isReturningUser.asStateFlow() + + init { + viewModelScope.launch { + _isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() } + } + } +} \ No newline at end of file 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 4041b87c5..119e8637d 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 @@ -15,14 +15,14 @@ import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.Wallpapers +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy @@ -47,23 +47,39 @@ 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.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.item.dialog.SelectItemContent import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.compose.koinInject class MainActivity : FragmentActivity() { + + private val viewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { + viewModel.isReturningUser.value == null + } + enableEdgeToEdge() setContent { + val hasAccess by viewModel.isReturningUser.collectAsState() + hasAccess ?: return@setContent + KeyGoTheme { val snackbarManager = koinInject() CompositionLocalProvider( LocalSnackbarManager provides snackbarManager, ) { - App() + App(hasAccess = hasAccess == true) } } } @@ -71,11 +87,8 @@ class MainActivity : FragmentActivity() { } @OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Preview(wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE) -@Preview(wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE, device = "spec:width=673dp,height=841dp") -@Preview(wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE, device = "id:desktop_large") @Composable -private fun App() { +private fun App(hasAccess: Boolean) { val listNavigator = rememberListDetailPaneScaffoldNavigator() val navController = rememberNavController() @@ -98,7 +111,6 @@ private fun App() { SnackbarHandler(snackbarHostState) val scope = rememberCoroutineScope() - KeyGoNavigationWrapper( currentDestination = currentDestination, navigateToTopLevelDestination = { @@ -130,8 +142,17 @@ private fun App() { ) { NavHost( navController = navController, - startDestination = AuthRoute(), + startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), ) { + totpImportRedirectGraph( + hasAccess = hasAccess, + navigateAndReplace = { dest -> + navController.navigate(dest) { + popUpTo { inclusive = true } + } + } + ) + authGraph( onSuccess = { totpUri -> val dest = totpUri?.let { @@ -144,6 +165,18 @@ private fun App() { } ) + onboardingGraph( + onSuccess = { totpUri -> + val dest = totpUri?.let { + RouteDestination.Home.Root(it) + } ?: RouteDestination.TopLevelAppGraph + + navController.navigate(dest) { + popUpTo { inclusive = true } + } + } + ) + navigation( startDestination = RouteDestination.Home.NavGraph ) { 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 new file mode 100644 index 000000000..60a420298 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/TotpImportRedirect.kt @@ -0,0 +1,42 @@ +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/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt b/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt new file mode 100644 index 000000000..b6c381712 --- /dev/null +++ b/app/src/test/kotlin/de/davis/keygo/app/presentation/TotpImportNavGraphTest.kt @@ -0,0 +1,120 @@ +package de.davis.keygo.app.presentation + +import androidx.core.net.toUri +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.DialogNavigator +import androidx.navigation.createGraph +import androidx.navigation.testing.TestNavHostController +import androidx.navigation.toRoute +import androidx.test.core.app.ApplicationProvider +import de.davis.keygo.core.ui.model.PendingTotpImport +import de.davis.keygo.feature.auth.presentation.AuthRoute +import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.onboarding.presentation.OnboardingRoute +import de.davis.keygo.feature.onboarding.presentation.onboardingGraph +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TotpImportNavGraphTest { + + private fun navController(hasAccess: Boolean): TestNavHostController { + val controller = + TestNavHostController(ApplicationProvider.getApplicationContext()) + controller.navigatorProvider.addNavigator(ComposeNavigator()) + controller.navigatorProvider.addNavigator(DialogNavigator()) + + controller.graph = controller.createGraph( + startDestination = if (hasAccess) AuthRoute() else OnboardingRoute(), + ) { + totpImportRedirectGraph(hasAccess = hasAccess, navigateAndReplace = {}) + authGraph(onSuccess = {}) + onboardingGraph(onSuccess = {}) + } + + return controller + } + + @Test + fun `graph builds for an account that already has access`() { + val controller = navController(hasAccess = true) + + assertTrue(controller.currentDestination?.hasRoute() == true) + } + + @Test + fun `graph builds for an account without access`() { + val controller = navController(hasAccess = false) + + assertTrue(controller.currentDestination?.hasRoute() == true) + } + + @Test + fun `otpauth deep link resolves to the redirect destination`() { + val controller = navController(hasAccess = true) + + controller.navigate("otpauth://totp/Example:me@example.com?secret=ABC".toUri()) + + val entry = assertNotNull(controller.currentBackStackEntry) + assertTrue(entry.destination.hasRoute()) + + val route = entry.toRoute() + assertEquals("Example:me@example.com", route.totpInfo) + assertEquals("secret=ABC", route.queries) + assertEquals( + "otpauth://totp/Example:me@example.com?secret=ABC", + route.pendingImport.uri, + ) + } + + @Test + fun `AuthRoute round trips the pending import through the back stack`() { + val controller = navController(hasAccess = true) + val redirect = TotpImportRedirect( + totpInfo = "Example:me@example.com", + queries = "secret=ABC", + ) + + controller.navigate( + AuthRoute(totpInfo = redirect.totpInfo, queries = redirect.queries), + ) + + val route = assertNotNull(controller.currentBackStackEntry).toRoute() + assertEquals(redirect.pendingImport, route.pendingTotpImport) + assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", route.uri) + } + + @Test + fun `OnboardingRoute round trips the pending import through the back stack`() { + val controller = navController(hasAccess = false) + val redirect = TotpImportRedirect( + totpInfo = "Example:me@example.com", + queries = "secret=ABC", + ) + + controller.navigate( + OnboardingRoute(totpInfo = redirect.totpInfo, queries = redirect.queries), + ) + + val route = assertNotNull(controller.currentBackStackEntry).toRoute() + assertEquals(redirect.pendingImport, route.pendingTotpImport) + assertEquals("otpauth://totp/Example:me@example.com?secret=ABC", route.uri) + } + + @Test + fun `a plain launch carries no pending import`() { + val controller = navController(hasAccess = true) + + val route = assertNotNull(controller.currentBackStackEntry).toRoute() + assertEquals(PendingTotpImport(), route.pendingTotpImport) + assertNull(route.uri) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 01c33859b..7dddcfd3e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) } android { diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt new file mode 100644 index 000000000..ef2a1d156 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/PendingTotpImport.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.core.ui.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PendingTotpImport( + val totpInfo: String? = null, + val queries: String? = null, +) { + val uri: String? + get() = if (!totpInfo.isNullOrBlank() && !queries.isNullOrBlank()) + "otpauth://totp/$totpInfo?$queries" + else null + + companion object { + const val BASE_PATH = "otpauth://totp" + const val URI_PATTERN = "otpauth://totp/{totpInfo}?{queries}" + } +} diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/UiFieldError.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/UiFieldError.kt new file mode 100644 index 000000000..b816f97d6 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/model/UiFieldError.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.core.ui.model + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.ui.R + +sealed interface UiFieldError { + + data object Incorrect : UiFieldError + data object Mismatch : UiFieldError + data object Empty : UiFieldError +} + +val UiFieldError.error: String + @Composable + get() = stringResource( + when (this) { + is UiFieldError.Incorrect -> R.string.incorrect_password + is UiFieldError.Mismatch -> R.string.password_does_not_match + is UiFieldError.Empty -> R.string.blank_password + } + ) \ No newline at end of file diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index ec096d5ec..42fd502cf 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -14,5 +14,9 @@ Hide password + You must set a password + Password is incorrect + Password does not match + Add \ No newline at end of file diff --git a/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt b/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt new file mode 100644 index 000000000..a35a443c8 --- /dev/null +++ b/core/ui/src/test/kotlin/de/davis/keygo/core/ui/model/PendingTotpImportTest.kt @@ -0,0 +1,49 @@ +package de.davis.keygo.core.ui.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PendingTotpImportTest { + + @Test + fun `uri rebuilds the full otpauth string when both parts are present`() { + val pending = PendingTotpImport( + totpInfo = "Example:me@example.com", + queries = "secret=JBSWY3DPEHPK3PXP&issuer=Example", + ) + assertEquals( + "otpauth://totp/Example:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + pending.uri, + ) + } + + @Test + fun `uri is null when totpInfo is missing`() { + val pending = PendingTotpImport(totpInfo = null, queries = "secret=ABC") + assertNull(pending.uri) + } + + @Test + fun `uri is null when queries is missing`() { + val pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = null) + assertNull(pending.uri) + } + + @Test + fun `uri is null when totpInfo is blank`() { + val pending = PendingTotpImport(totpInfo = " ", queries = "secret=ABC") + assertNull(pending.uri) + } + + @Test + fun `uri is null when queries is blank`() { + val pending = PendingTotpImport(totpInfo = "Example:me@example.com", queries = " ") + assertNull(pending.uri) + } + + @Test + fun `default construction has no pending uri`() { + assertNull(PendingTotpImport().uri) + } +} diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt index e8bf893a4..07191375c 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthContent.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -49,18 +48,21 @@ import androidx.compose.ui.text.style.TextAlign 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.presentation.StrengthIndicator import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.core.ui.model.error import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.auth.R import de.davis.keygo.feature.auth.presentation.model.AuthState import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent -import de.davis.keygo.feature.auth.presentation.model.UIPasswordError import de.davis.keygo.core.item.R as CoreItemR @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun AuthContent(state: AuthState, onEvent: (AuthUIEvent) -> Unit) { +fun AuthContent( + state: AuthState, + onEvent: (AuthUIEvent) -> Unit, + hasPendingTotpImport: Boolean = false, +) { when (state) { is AuthState.Loading -> { Surface(modifier = Modifier.fillMaxSize()) { @@ -73,7 +75,11 @@ fun AuthContent(state: AuthState, onEvent: (AuthUIEvent) -> Unit) { } } - is AuthState.Interactable -> InteractableAuthContent(state = state, onEvent = onEvent) + is AuthState.Interactable -> InteractableAuthContent( + state = state, + onEvent = onEvent, + hasPendingTotpImport = hasPendingTotpImport, + ) } } @@ -82,6 +88,7 @@ fun AuthContent(state: AuthState, onEvent: (AuthUIEvent) -> Unit) { private fun InteractableAuthContent( state: AuthState.Interactable, onEvent: (AuthUIEvent) -> Unit, + hasPendingTotpImport: Boolean, ) { Surface(modifier = Modifier.fillMaxSize()) { Box( @@ -122,29 +129,25 @@ private fun InteractableAuthContent( textAlign = TextAlign.Center, ) + if (hasPendingTotpImport) + Text( + text = stringResource(R.string.pending_totp_import_subtitle), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + with(state) { var passwordHidden by rememberSaveable { mutableStateOf(true) } - var forceCompact by rememberSaveable { mutableStateOf(false) } OutlinedSecureTextField( state = passwordTextFieldState, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - forceCompact = !it.hasFocus - }, + modifier = Modifier.fillMaxWidth(), label = { Text(text = stringResource(CoreItemR.string.password)) }, - isError = passwordError !is UIPasswordError.None, - supportingText = when (passwordError) { - is UIPasswordError.None -> null - is UIPasswordError.Incorrect -> { - { Text(stringResource(R.string.incorrect_password)) } - } - - else -> { - { Text(stringResource(R.string.blank_password)) } - } + isError = passwordError != null, + supportingText = passwordError?.let { + { Text(it.error) } }, textObfuscationMode = when { passwordHidden -> TextObfuscationMode.RevealLastTyped @@ -158,46 +161,7 @@ private fun InteractableAuthContent( }, ) - if (this is AuthState.CreateAccess) { - StrengthIndicator( - passwordScore = passwordScore, - forceCompact = forceCompact, - ) - - var confirmPasswordHidden by rememberSaveable { mutableStateOf(true) } - OutlinedSecureTextField( - state = confirmPasswordTextFieldState, - modifier = Modifier.fillMaxWidth(), - label = { - Text(text = stringResource(R.string.confirm_password)) - }, - isError = confirmPasswordError !is UIPasswordError.None, - supportingText = when (confirmPasswordError) { - is UIPasswordError.None -> null - is UIPasswordError.Incorrect -> { - { Text(stringResource(R.string.password_does_not_match)) } - } - - else -> { - { Text(stringResource(R.string.blank_password)) } - } - }, - textObfuscationMode = when { - confirmPasswordHidden -> TextObfuscationMode.RevealLastTyped - else -> TextObfuscationMode.Visible - }, - trailingIcon = { - VisibilityButton( - isHidden = confirmPasswordHidden, - onClick = { - confirmPasswordHidden = !confirmPasswordHidden - }, - ) - }, - ) - } - - if (state is AuthState.BiometricAuthState && state.biometricsAvailable) + if (state is AuthState.Migrating && state.biometricsAvailable) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -299,7 +263,6 @@ private val AuthState.Interactable.firstTitlePart: String get() = when (this) { is AuthState.Login -> stringResource(R.string.authenticate_to_access) is AuthState.Migrating -> stringResource(R.string.migrate_to_access) - is AuthState.CreateAccess -> stringResource(R.string.create_access_access) } private val AuthState.Interactable.buttonText: String @@ -307,7 +270,6 @@ private val AuthState.Interactable.buttonText: String get() = when (this) { is AuthState.Login -> stringResource(R.string.authenticate) is AuthState.Migrating -> stringResource(R.string.migrate) - is AuthState.CreateAccess -> stringResource(R.string.create_access) } @Composable @@ -318,7 +280,7 @@ private val AuthState.Interactable.buttonText: String private fun AuthContentPreview() { KeyGoTheme { AuthContent( - state = AuthState.CreateAccess( + state = AuthState.Migrating( passwordTextFieldState = TextFieldState(), biometricsAvailable = true ), diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt index 6324e1535..ab34ab9e7 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthGraph.kt @@ -2,21 +2,14 @@ package de.davis.keygo.feature.auth.presentation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink import androidx.navigation.toRoute fun NavGraphBuilder.authGraph(onSuccess: (String?) -> Unit) { - composable( - deepLinks = listOf( - navDeepLink(basePath = "otpauth://totp") { - uriPattern = "otpauth://totp/{totpInfo}?{queries}" - } - ) - ) { s -> + composable { s -> AuthScreen( onSuccess = { onSuccess(s.toRoute().uri) } ) } -} \ No newline at end of file +} diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt index 80ad7dcb9..33ce451fb 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthScreen.kt @@ -53,5 +53,9 @@ fun AuthScreen(onSuccess: () -> Unit) { } } - AuthContent(state = state, onEvent = viewModel::onEvent) + AuthContent( + state = state, + onEvent = viewModel::onEvent, + hasPendingTotpImport = viewModel.hasPendingTotpImport, + ) } \ No newline at end of file diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt index a25da324d..4ddb3de11 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt @@ -1,7 +1,6 @@ package de.davis.keygo.feature.auth.presentation import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -9,8 +8,8 @@ import androidx.navigation.toRoute import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase -import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.onFailure @@ -18,29 +17,17 @@ import de.davis.keygo.core.util.onSuccess import de.davis.keygo.feature.auth.presentation.model.AuthState import de.davis.keygo.feature.auth.presentation.model.AuthUIEvent import de.davis.keygo.feature.auth.presentation.model.BiometricRequest -import de.davis.keygo.feature.auth.presentation.model.UIPasswordError import de.davis.keygo.migration.create_access.domain.usecase.ClearMainPasswordUseCase import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase import de.davis.keygo.migration.create_access.domain.usecase.ValidateMainPasswordUseCase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import javax.crypto.Cipher -import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class AuthViewModel( @@ -54,17 +41,17 @@ internal class AuthViewModel( private val clearMainPasswordUseCase: ClearMainPasswordUseCase, // ------------------- - private val passwordStrengthEstimator: PasswordStrengthEstimator, private val unlockWithPassword: UnlockWithPasswordUseCase, private val createAllAccesses: CreateAccessUseCase, ) : ViewModel() { - private val biometricChannel = Channel() + private val biometricChannel = Channel(Channel.BUFFERED) val biometricFlow = biometricChannel.receiveAsFlow() private val authRoute = savedStateHandle.toRoute() + val hasPendingTotpImport: Boolean = authRoute.uri != null + private val passwordTextFieldState = TextFieldState() - private val confirmPasswordTextFieldState = TextFieldState() private val _uiState = MutableStateFlow(AuthState.Loading) val uiState = _uiState.asStateFlow() @@ -73,8 +60,7 @@ internal class AuthViewModel( viewModelScope.launch { val activeAccount = accountRepository.getOrNull() val hasAccess = activeAccount != null - val hasAccessButShouldMigrate = if (!hasAccess) hasV1MainPassword() - else false + val shouldMigrate = if (!hasAccess) hasV1MainPassword() else false val isBiometricHardwareAvailable = biometricAvailabilityRepository.availability() val isBiometricCryptoSetupAvailable = @@ -86,86 +72,22 @@ internal class AuthViewModel( _uiState.update { when { - hasAccessButShouldMigrate -> { + shouldMigrate -> { AuthState.Migrating( passwordTextFieldState = passwordTextFieldState, biometricsAvailable = isBiometricHardwareAvailable, ) } - hasAccess -> AuthState.Login( + else -> AuthState.Login( passwordTextFieldState = passwordTextFieldState, biometricAuthenticationAvailable = biometricsUsable ) - - else -> { - observePasswordStrength() - observePasswordError() - observeConfirmPasswordError() - - AuthState.CreateAccess( - passwordTextFieldState = passwordTextFieldState, - confirmPasswordTextFieldState = confirmPasswordTextFieldState, - biometricsAvailable = isBiometricHardwareAvailable - ) - } } } } } - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - private fun observePasswordError() { - snapshotFlow { passwordTextFieldState.text } - .debounce(150.milliseconds) - .mapLatest { it.isBlank() } - .distinctUntilChanged() - .onEach { isBlank -> - if (isBlank) return@onEach - - _uiState.update { - if (it !is AuthState.CreateAccess) return@update it - it.copy(passwordError = UIPasswordError.None) - } - } - .flowOn(Dispatchers.Default) - .launchIn(viewModelScope) - } - - private fun observeConfirmPasswordError() { - snapshotFlow { passwordTextFieldState.text } - .combine(snapshotFlow { confirmPasswordTextFieldState.text }) { password, confirm -> - password == confirm - } - .distinctUntilChanged() - .onEach { equal -> - if (!equal) return@onEach - - _uiState.update { - if (it !is AuthState.CreateAccess) return@update it - it.copy(confirmPasswordError = UIPasswordError.None) - } - } - .flowOn(Dispatchers.Default) - .launchIn(viewModelScope) - } - - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - private fun observePasswordStrength() { - snapshotFlow { passwordTextFieldState.text } - .debounce(150.milliseconds) - .mapLatest { passwordStrengthEstimator(it.toString()) } - .distinctUntilChanged() - .onEach { score -> - _uiState.update { - if (it !is AuthState.CreateAccess) return@update it - it.copy(passwordScore = score) - } - } - .flowOn(Dispatchers.Default) - .launchIn(viewModelScope) - } - private val navigationEventChannel = Channel() val navigationEvent = navigationEventChannel.receiveAsFlow() @@ -182,7 +104,7 @@ internal class AuthViewModel( unlockWithPassword( password = password ).handleAuthenticationResult { - copyDefaultState(passwordError = UIPasswordError.Incorrect) + copyDefaultState(passwordError = UiFieldError.Incorrect) } } } @@ -193,7 +115,7 @@ internal class AuthViewModel( .onFailure { _uiState.update { if (it !is AuthState.Interactable) return@update it - it.copyDefaultState(passwordError = UIPasswordError.Incorrect) + it.copyDefaultState(passwordError = UiFieldError.Incorrect) } }.onSuccess { clearMainPasswordUseCase() @@ -201,29 +123,6 @@ internal class AuthViewModel( } } } - - is AuthState.CreateAccess -> { - val errorFreeState = state.copy( - passwordError = UIPasswordError.None, - confirmPasswordError = UIPasswordError.None - ) - - if (password.isBlank()) { - _uiState.update { - errorFreeState.copy(passwordError = UIPasswordError.Empty) - } - return - } - val confirmedPassword = confirmPasswordTextFieldState.text.toString() - if (password != confirmedPassword) { - _uiState.update { - errorFreeState.copy(confirmPasswordError = UIPasswordError.Incorrect) - } - return - } - - createPasswordOrBiometricAccess(errorFreeState, password) - } } } @@ -236,15 +135,15 @@ internal class AuthViewModel( is AuthUIEvent.ToggleUseBiometrics -> { _uiState.update { - if (it !is AuthState.BiometricAuthState) return@update it - it.copyBiometricState(useBiometrics = event.checked) + if (it !is AuthState.Migrating) return@update it + it.copy(useBiometrics = event.checked) } } } } private fun createPasswordOrBiometricAccess( - authState: AuthState.BiometricAuthState, + authState: AuthState.Migrating, password: String ) { if (!authState.biometricsAvailable || !authState.useBiometrics) { diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt index 1f07323ac..ea6e84bbf 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/RouteDestination.kt @@ -1,16 +1,23 @@ package de.davis.keygo.feature.auth.presentation import de.davis.keygo.core.ui.RouteDestination +import de.davis.keygo.core.ui.model.PendingTotpImport import kotlinx.serialization.Serializable +/** + * The pending import travels as primitives, not as a [PendingTotpImport] field. Type-safe + * navigation has no [androidx.navigation.NavType] for a custom class unless one is supplied + * through a typeMap, and building the graph without it throws while the graph is created. + */ @Serializable data class AuthRoute( val totpInfo: String? = null, val queries: String? = null, - val showBiometricPromptIfPossible: Boolean = true + val showBiometricPromptIfPossible: Boolean = true, ) : RouteDestination { - val uri - get() = if (!totpInfo.isNullOrBlank() && !queries.isNullOrBlank()) - "otpauth://totp/$totpInfo?$queries" - else null -} \ No newline at end of file + val pendingTotpImport: PendingTotpImport + get() = PendingTotpImport(totpInfo, queries) + + val uri: String? + get() = pendingTotpImport.uri +} diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthEvent.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthEvent.kt deleted file mode 100644 index bd8657b24..000000000 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.davis.keygo.feature.auth.presentation.model - -internal sealed interface AuthEvent { - - data object Success : AuthEvent - data object Failure : AuthEvent - data object None : AuthEvent -} \ No newline at end of file diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt index e67983940..330a32306 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/AuthState.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.auth.presentation.model import androidx.compose.foundation.text.input.TextFieldState -import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.ui.model.UiFieldError sealed interface AuthState { @@ -9,13 +9,13 @@ sealed interface AuthState { sealed interface Interactable : AuthState { val passwordTextFieldState: TextFieldState - val passwordError: UIPasswordError + val passwordError: UiFieldError? val loading: Boolean fun copyDefaultState( loading: Boolean = this.loading, passwordTextFieldState: TextFieldState = this.passwordTextFieldState, - passwordError: UIPasswordError = this.passwordError, + passwordError: UiFieldError? = this.passwordError, ): Interactable = when (this) { is Login -> copy( loading = loading, @@ -23,12 +23,6 @@ sealed interface AuthState { passwordError = passwordError, ) - is CreateAccess -> copy( - loading = loading, - passwordTextFieldState = passwordTextFieldState, - passwordError = passwordError, - ) - is Migrating -> copy( loading = loading, passwordTextFieldState = passwordTextFieldState, @@ -39,49 +33,17 @@ sealed interface AuthState { data class Login( override val passwordTextFieldState: TextFieldState, - override val passwordError: UIPasswordError = UIPasswordError.None, + override val passwordError: UiFieldError? = null, override val loading: Boolean = false, val biometricAuthenticationAvailable: Boolean = false, ) : Interactable - - sealed interface BiometricAuthState : Interactable { - val biometricsAvailable: Boolean - val useBiometrics: Boolean - - fun copyBiometricState( - biometricsAvailable: Boolean = this.biometricsAvailable, - useBiometrics: Boolean = this.useBiometrics, - ): BiometricAuthState = when (this) { - is CreateAccess -> copy( - biometricsAvailable = biometricsAvailable, - useBiometrics = useBiometrics - ) - - is Migrating -> copy( - biometricsAvailable = biometricsAvailable, - useBiometrics = useBiometrics - ) - } - } - data class Migrating( override val passwordTextFieldState: TextFieldState, - override val passwordError: UIPasswordError = UIPasswordError.None, + override val passwordError: UiFieldError? = null, override val loading: Boolean = false, - override val biometricsAvailable: Boolean = false, - override val useBiometrics: Boolean = true, + val biometricsAvailable: Boolean = false, + val useBiometrics: Boolean = true, val showMigrationDialog: Boolean = true - ) : BiometricAuthState - - data class CreateAccess( - override val passwordTextFieldState: TextFieldState, - override val passwordError: UIPasswordError = UIPasswordError.None, - override val loading: Boolean = false, - override val biometricsAvailable: Boolean = false, - override val useBiometrics: Boolean = true, - val confirmPasswordTextFieldState: TextFieldState = TextFieldState(), - val confirmPasswordError: UIPasswordError = UIPasswordError.None, - val passwordScore: PasswordScore = PasswordScore.None, - ) : BiometricAuthState + ) : Interactable } diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/UIPasswordError.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/UIPasswordError.kt deleted file mode 100644 index fed1e821b..000000000 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/model/UIPasswordError.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.davis.keygo.feature.auth.presentation.model - -sealed interface UIPasswordError { - - data object Incorrect : UIPasswordError - data object Empty : UIPasswordError - data object None : UIPasswordError -} \ No newline at end of file diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml index d8b7bc9ae..e795c0efd 100644 --- a/feature/auth/src/main/res/values/strings.xml +++ b/feature/auth/src/main/res/values/strings.xml @@ -1,9 +1,6 @@ Confirm Password - You must set a password - Password is incorrect! - Password does not match Authenticate Unlock %s Create your access @@ -20,4 +17,5 @@ To ensure the integrity of your data, please migrate your access credentials. You will be prompted to enter your master password. Request biometric authentication + Unlock your vault to add this authenticator code. \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt index 901f21d07..a656c23d2 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/data/repository/ChromeAutofillRepositoryImpl.kt @@ -3,8 +3,12 @@ package de.davis.keygo.feature.autofill.data.repository import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.database.Cursor import android.net.Uri +import android.util.Log import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single @Single @@ -12,26 +16,40 @@ internal class ChromeAutofillRepositoryImpl( private val context: Context, ) : ChromeAutofillRepository { - override fun isAutofillEnabled(): Boolean { - val uri = Uri.Builder() + private val thirdPartyModeUri: Uri + get() = Uri.Builder() .scheme(ContentResolver.SCHEME_CONTENT) .authority(CHROME_CHANNEL_PACKAGE + CONTENT_PROVIDER_NAME) .path(THIRD_PARTY_MODE_ACTIONS_URI_PATH) .build() - return context.contentResolver.query( - uri, - arrayOf(THIRD_PARTY_MODE_COLUMN), - null, - null, - null, - )?.use { - if (!it.moveToFirst()) return false - - val thirdPartyModeState = it.getInt(it.getColumnIndexOrThrow(THIRD_PARTY_MODE_COLUMN)) - thirdPartyModeState == 1 // 1 means third-party autofill is enabled. - } == true - } + private suspend fun useQueryThirdPartyMode(block: (Cursor) -> R): R? = + withContext(Dispatchers.IO) { + try { + context.contentResolver.query( + thirdPartyModeUri, + arrayOf(THIRD_PARTY_MODE_COLUMN), + null, + null, + null, + )?.use(block) + } catch (e: RuntimeException) { + // A provider that answers for the authority but refuses the read is + // indistinguishable, for our purposes, from no provider at all. + Log.w(TAG, "Failed to query Chrome's third party autofill mode provider", e) + null + } + } + + override suspend fun isAvailable(): Boolean = useQueryThirdPartyMode { true } == true + + override suspend fun isAutofillEnabled(): Boolean = useQueryThirdPartyMode { cursor -> + if (!cursor.moveToFirst()) return@useQueryThirdPartyMode false + + val thirdPartyModeState = + cursor.getInt(cursor.getColumnIndexOrThrow(THIRD_PARTY_MODE_COLUMN)) + thirdPartyModeState == 1 // 1 means third-party autofill is enabled. + } == true override fun openChromeAutofillSettings() { val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES).apply { @@ -46,9 +64,11 @@ internal class ChromeAutofillRepositoryImpl( } private companion object { - private val CHROME_CHANNEL_PACKAGE = "com.android.chrome" // Chrome Stable. - private val CONTENT_PROVIDER_NAME = ".AutofillThirdPartyModeContentProvider" - private val THIRD_PARTY_MODE_COLUMN = "autofill_third_party_state" - private val THIRD_PARTY_MODE_ACTIONS_URI_PATH = "autofill_third_party_mode" + private const val TAG = "ChromeAutofillRepositoryImpl" + + private const val CHROME_CHANNEL_PACKAGE = "com.android.chrome" + private const val CONTENT_PROVIDER_NAME = ".AutofillThirdPartyModeContentProvider" + private const val THIRD_PARTY_MODE_COLUMN = "autofill_third_party_state" + private const val THIRD_PARTY_MODE_ACTIONS_URI_PATH = "autofill_third_party_mode" } } \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt index 8ad5d0322..20f853bf1 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/repository/ChromeAutofillRepository.kt @@ -2,7 +2,14 @@ package de.davis.keygo.feature.autofill.domain.repository interface ChromeAutofillRepository { - fun isAutofillEnabled(): Boolean + /** + * Whether Chrome is installed and exposes third party autofill mode. Callers that offer to open + * Chrome's settings should check this first: without the provider there is nothing to read and + * nothing for the user to turn on. + */ + suspend fun isAvailable(): Boolean + + suspend fun isAutofillEnabled(): Boolean fun openChromeAutofillSettings() -} \ No newline at end of file +} diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt index 9d6474263..4d8d95ab0 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeChromeAutofillRepository.kt @@ -4,11 +4,17 @@ import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepositor class FakeChromeAutofillRepository : ChromeAutofillRepository { + // Chrome is present and exposes third party autofill mode. Flip it to model a device with no + // Chrome, where the enabled read can never come back true. + var available: Boolean = true + var enabled: Boolean = false var openCalled: Boolean = false - override fun isAutofillEnabled(): Boolean = enabled + override suspend fun isAvailable(): Boolean = available + + override suspend fun isAutofillEnabled(): Boolean = available && enabled override fun openChromeAutofillSettings() { openCalled = true diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt index bfed18ce4..66f22f16e 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt @@ -9,4 +9,16 @@ enum class FileFormat(val mimeType: String, val extension: String) { val encrypted: Boolean get() = this == JSON + + companion object { + + /** + * The format [fileName] implies, or null when KeyGo has no importer for it. The extension + * decides, not the MIME type: content providers routinely report the wrong type for a CSV, + * so the file picker cannot filter tightly enough to keep an unsupported file out. + */ + fun fromFileName(fileName: String?): FileFormat? = fileName?.let { name -> + entries.firstOrNull { name.endsWith(".${it.extension}", ignoreCase = true) } + } + } } \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt index 7bf4d1490..51fc86831 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt @@ -6,6 +6,7 @@ sealed interface ImportError { data object SessionLocked : ImportError data object FileUnreadable : ImportError data object EmptyFile : ImportError + data object UnsupportedFormat : ImportError data object WrongCredential : ImportError data object PassphraseRequired : ImportError data class ParseFailed(val cause: BackupException) : ImportError diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt index f0a3142c1..8cb0d8884 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt @@ -33,7 +33,7 @@ import de.davis.keygo.feature.backup.R import de.davis.keygo.feature.backup.domain.model.BackupDestination @Composable -internal fun BackupFileChooser( +fun BackupFileChooser( destination: BackupDestination?, onChoose: () -> Unit, chooserIcon: ImageVector, @@ -45,7 +45,7 @@ internal fun BackupFileChooser( modifier: Modifier = Modifier, ) { when (destination) { - null -> ChooserCard( + null -> BackupFileChooserCard( icon = chooserIcon, title = chooserTitle, subtitle = chooserSubtitle, @@ -64,8 +64,12 @@ internal fun BackupFileChooser( } } +/** + * The empty state of [BackupFileChooser], exposed on its own for hosts that never show a selected + * file because picking one immediately hands off somewhere else. + */ @Composable -private fun ChooserCard( +fun BackupFileChooserCard( icon: ImageVector, title: String, subtitle: String, diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportFilePicker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportFilePicker.kt new file mode 100644 index 000000000..792c2a0f2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportFilePicker.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.FileFormat + +private val ImportFileMimeTypes = (FileFormat.entries.map { it.mimeType } + "*/*").toTypedArray() + +fun interface FilePickerAction { + fun launch() +} + +@Composable +fun rememberImportFilePicker(onPicked: (BackupDestinationUri) -> Unit): FilePickerAction { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + uri?.let { onPicked(BackupDestinationUri(it.toString())) } + } + return remember(launcher) { FilePickerAction { launcher.launch(ImportFileMimeTypes) } } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt index 81fe3871b..b9ba8bfc6 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt @@ -203,6 +203,7 @@ internal fun ImportErrorContent( val message = when (error) { ImportError.FileUnreadable -> stringResource(R.string.import_error_file_unreadable) ImportError.EmptyFile -> stringResource(R.string.import_error_empty) + ImportError.UnsupportedFormat -> stringResource(R.string.import_error_unsupported) ImportError.NothingImported -> stringResource(R.string.import_error_nothing) ImportError.SessionLocked -> stringResource(R.string.import_error_session_locked) is ImportError.ParseFailed -> stringResource(R.string.import_error_parse) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt index 1dfa586d5..434f8830a 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt @@ -37,6 +37,7 @@ internal fun ImportWizardContent( state: ImportWizardUiState, onEvent: (ImportWizardUiEvent) -> Unit, navigateUp: () -> Unit, + onFinished: () -> Unit = navigateUp, ) { AnimatedContent( targetState = state.progress, @@ -46,7 +47,7 @@ internal fun ImportWizardContent( when (progress) { is ImportProgress.Succeeded -> ImportResultContent( summary = progress.summary, - onDone = navigateUp, + onDone = onFinished, ) is ImportProgress.Failed -> ImportErrorContent( @@ -176,6 +177,7 @@ private class ImportWizardUiStateProvider : PreviewParameterProvider Unit) { +fun ImportWizardScreen( + navigateUp: () -> Unit, + preselectedFile: BackupDestinationUri? = null, + onFinished: () -> Unit = navigateUp, +) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val filePicker = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri -> - viewModel.onFilePicked(uri?.let { BackupDestinationUri(it.toString()) }) - } + val chooseFile = rememberImportFilePicker(viewModel::onFilePicked) ObserveAsEvents(flow = viewModel.event) { when (it) { - ImportWizardEvent.PickFile -> filePicker.launch( - arrayOf("application/json", "text/csv", "*/*"), - ) + ImportWizardEvent.PickFile -> chooseFile.launch() + + ImportWizardEvent.Exit -> navigateUp() } } + LaunchedEffect(preselectedFile) { + if (preselectedFile != null) viewModel.seedFile(preselectedFile) + } + + // seedFile lands on the next composition, so for one frame the state still says the wizard owns + // file selection. Showing the reading phase for that frame keeps the chooser in one place. + val displayState = if (preselectedFile != null && !state.fileChosenByHost) + state.copy(progress = ImportProgress.Reading) + else state + + // While a host owns the file, the wizard is the only thing that may answer back: it has no back + // stack of its own to fall through to, and an unhandled press would finish the host's Activity + // mid import. So this stays enabled for every phase and swallows the press where back is inert, + // rather than letting the host compensate with a no-op handler of its own. Wizard's + // PredictiveBackHandler is composed deeper and still wins wherever it is enabled. + BackHandler(enabled = preselectedFile != null) { + if (displayState.backEnabled) viewModel.onEvent(ImportWizardUiEvent.Back) + } + + // Wizard's own toolbar arrow calls navigateUp directly on the first step it renders, bypassing + // onBack(). A seeded wizard needs that arrow on the same path as the back gesture above, or + // tapping it would leave a running import and the seed in place instead of tearing them down. + val onBackToHost: () -> Unit = { viewModel.onEvent(ImportWizardUiEvent.Back) } + ImportWizardContent( - state = state, + state = displayState, onEvent = viewModel::onEvent, - navigateUp = navigateUp, + navigateUp = if (preselectedFile != null) onBackToHost else navigateUp, + onFinished = onFinished, ) } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt index ec58a1193..60b2b0469 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt @@ -1,10 +1,12 @@ package de.davis.keygo.feature.backup.presentation.import import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.getIdOrNull import de.davis.keygo.core.util.fold import de.davis.keygo.feature.backup.domain.BackupDestinationResolver @@ -22,6 +24,7 @@ import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardEvent import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardStep import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiEvent import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiState +import de.davis.keygo.feature.backup.presentation.import.model.previousStep import de.davis.keygo.feature.backup.presentation.import.model.toMappingRows import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import de.davisalessandro.keygo.rust.ColumnMapping @@ -60,7 +63,9 @@ internal class ImportWizardViewModel( private var importJob: Job? = null private var analysisJob: Job? = null + private var seedJob: Job? = null private var vaultStepSeeded = false + private var seededUri: BackupDestinationUri? = null init { snapshotFlow { passphraseState.text.toString() } @@ -121,6 +126,37 @@ internal class ImportWizardViewModel( } } + /** + * Enters the wizard on a file the host picked, so that choosing a file stays one action in one + * place. The file step is skipped and the lane resumes at the first question the wizard still + * has, which is the column mapping for a CSV and nothing at all for a JSON that imports cleanly. + * + * Idempotent per file. The screen seeds from a [androidx.compose.runtime.LaunchedEffect], which + * restarts on a configuration change, and re-running the import there would throw away the + * mapping the user was in the middle of. + */ + fun seedFile(uri: BackupDestinationUri) { + if (seededUri == uri) return + seededUri = uri + + cancelInFlightWork() + _state.update { + it.cleared().copy( + uri = uri, + fileChosenByHost = true, + // Held until the lane below decides where the user lands, so the wizard never + // flashes the file step its host already owns. + progress = ImportProgress.Reading, + ) + } + + seedJob = viewModelScope.launch { + val destination = backupDestinationResolver.resolve(uri) + _state.update { it.copy(backupDestination = destination) } + onContinue() + } + } + private fun onContinue() = when (_state.value.step) { ImportWizardStep.SelectFile -> onSelectFileContinue() ImportWizardStep.MapColumns -> validateMapping() @@ -131,7 +167,12 @@ internal class ImportWizardViewModel( private fun onSelectFileContinue() = when (_state.value.format) { FileFormat.CSV -> runAnalysis() FileFormat.JSON -> startImport(passphrase = null) - null -> Unit + + // Reachable because the picker has to offer the wildcard type: providers routinely report + // the wrong MIME type for a .csv, so the filter cannot be tight enough to keep a .txt out. + null -> _state.update { + it.copy(progress = ImportProgress.Failed(ImportError.UnsupportedFormat)) + } } private fun runAnalysis() { @@ -151,6 +192,7 @@ internal class ImportWizardViewModel( columns = analysis.toMappingRows(), step = ImportWizardStep.MapColumns, duplicateTypes = emptySet(), + progress = null, ) } @@ -249,26 +291,83 @@ internal class ImportWizardViewModel( else -> _state.update { it.copy(progress = ImportProgress.Failed(error)) } } - private fun back() = _state.update { - when { - it.progress is ImportProgress.Failed -> it.copy( - progress = null, - step = ImportWizardStep.SelectFile, - passphraseError = false, - ) + private fun back() { + val current = _state.value + + // A failure restarts from the file. When the host picked it there is no file step to + // restart from, so the wizard hands control back instead. + if (current.progress is ImportProgress.Failed) { + if (current.fileChosenByHost) return exit() + + return _state.update { + it.copy( + progress = null, + step = ImportWizardStep.SelectFile, + passphraseError = false, + ) + } + } - it.step == ImportWizardStep.SelectVault -> it.copy(step = ImportWizardStep.MapColumns) + val previous = current.step.previousStep(current.fileChosenByHost) + if (previous == null) { + if (current.fileChosenByHost) exit() + return + } - it.step == ImportWizardStep.MapColumns -> it.copy( - step = ImportWizardStep.SelectFile, - columns = emptyList(), + // duplicateTypes can only be set on MapColumns (validateMapping refuses to advance while + // duplicates remain) and passphraseError only on ProvidePassphrase, so clearing both + // unconditionally is the same as clearing them per step. Only the columns are conditional. + _state.update { + it.copy( + step = previous, + columns = if (current.step == ImportWizardStep.MapColumns) emptyList() + else it.columns, duplicateTypes = emptySet(), + passphraseError = false, ) + } + } - it.step == ImportWizardStep.ProvidePassphrase -> - it.copy(step = ImportWizardStep.SelectFile, passphraseError = false) + /** + * Hands control back to whoever opened the wizard, stops any work still in flight, and resets + * the UI state to what a fresh wizard looks like. + * + * The reset matters because this ViewModel is scoped to the host's back stack entry, so it + * outlives the visit: without it, the previous file's screen (its error, or its column mapping) + * would render again for the gap between handing control back and the host seeding a new file. + */ + private fun exit() { + cancelInFlightWork() + seededUri = null + _state.update { it.cleared() } + _event.trySend(ImportWizardEvent.Exit) + } - else -> it - } + private fun cancelInFlightWork() { + importJob?.cancel() + analysisJob?.cancel() + seedJob?.cancel() + vaultStepSeeded = false + passphraseState.clearText() + newVaultNameState.clearText() } + + /** + * What a fresh wizard looks like. `vaults` and `contextVaultId` are owned by the observe flow + * rather than by a visit, so they survive; the text field states are held by this ViewModel and + * are cleared by [cancelInFlightWork]. + */ + private fun ImportWizardUiState.cleared() = copy( + step = ImportWizardStep.SelectFile, + fileChosenByHost = false, + uri = null, + backupDestination = null, + progress = null, + columns = emptyList(), + duplicateTypes = emptySet(), + passphraseError = false, + creatingNewVault = false, + selectedVaultId = null, + newVaultIcon = Vault.Icon.Default, + ) } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt index 05b080a98..7d6362be7 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt @@ -2,4 +2,7 @@ package de.davis.keygo.feature.backup.presentation.import.model internal sealed interface ImportWizardEvent { data object PickFile : ImportWizardEvent + + /** The wizard has nothing left to go back to and hands control to whoever opened it. */ + data object Exit : ImportWizardEvent } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt index 335891ba3..a989c5575 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt @@ -18,8 +18,34 @@ private val JsonLane = listOf( ImportWizardStep.ProvidePassphrase, ) -/** The steps walked so far on [step]'s lane, so the wizard can draw its progress. */ -internal fun importStepsFor(step: ImportWizardStep): List { - val lane = if (step == ImportWizardStep.ProvidePassphrase) JsonLane else CsvLane - return lane.subList(0, lane.indexOf(step) + 1) +/** + * The whole lane [step] sits on, which is the single place the step order lives. + * + * [includeFileStep] is false when the host picked the file before the wizard opened. That step + * never renders then, so it is not part of the walk at all. + */ +private fun laneFor(step: ImportWizardStep, includeFileStep: Boolean): List { + val full = if (step == ImportWizardStep.ProvidePassphrase) JsonLane else CsvLane + return if (includeFileStep) full else full - ImportWizardStep.SelectFile } + +/** + * The steps walked so far on [step]'s lane, so the wizard can draw its progress. Dropping the file + * step would otherwise leave the indicator permanently one dot ahead of the user. + */ +internal fun importStepsFor( + step: ImportWizardStep, + includeFileStep: Boolean = true, +): List = laneFor(step, includeFileStep) + .let { it.subList(0, it.indexOf(step) + 1) } + +/** + * The step back from this one, or null when there is nothing left inside the wizard to go back to. + * + * [fileChosenByHost] collapses the file step: the host owns it, so backing out of the first step + * the wizard actually renders means leaving the wizard rather than walking to + * [ImportWizardStep.SelectFile]. + */ +internal fun ImportWizardStep.previousStep(fileChosenByHost: Boolean): ImportWizardStep? = + laneFor(this, includeFileStep = !fileChosenByHost) + .let { it.getOrNull(it.indexOf(this) - 1) } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt index 4f076ee6b..182a2e6b7 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt @@ -19,6 +19,8 @@ internal data class ImportWizardUiState( val backupDestination: BackupDestination? = null, val uri: BackupDestinationUri? = null, val step: ImportWizardStep = ImportWizardStep.SelectFile, + /** True when the file was picked before the wizard opened, for example by onboarding. */ + val fileChosenByHost: Boolean = false, val passphraseValid: Boolean = false, val passphraseError: Boolean = false, val progress: ImportProgress? = null, @@ -32,36 +34,31 @@ internal data class ImportWizardUiState( /** The vault the user is currently working in; seeds the destination choice. */ val contextVaultId: VaultId? = null, ) { - val format: FileFormat? = backupDestination?.fileName?.let { name -> - when { - name.endsWith(".${FileFormat.JSON.extension}", ignoreCase = true) -> FileFormat.JSON - name.endsWith(".${FileFormat.CSV.extension}", ignoreCase = true) -> FileFormat.CSV - else -> null - } - } + val format: FileFormat? + get() = FileFormat.fromFileName(backupDestination?.fileName) + + val steps: List + get() = importStepsFor(step, includeFileStep = !fileChosenByHost) - val steps: List = importStepsFor(step) + val suggestedVaultName: String + get() = backupDestination?.fileName?.substringBeforeLast('.').orEmpty() - /** `passwords.csv` -> `passwords`. Distinct per import, unlike the parser's `CSV Import`. */ - val suggestedVaultName: String = - backupDestination?.fileName?.substringBeforeLast('.').orEmpty() + val canContinue: Boolean + get() = when (step) { + ImportWizardStep.SelectFile -> backupDestination != null + ImportWizardStep.MapColumns -> columns.any { it.selectedType != null } + ImportWizardStep.SelectVault -> + if (creatingNewVault) newVaultNameValid else selectedVaultId != null - val canContinue: Boolean = when (step) { - ImportWizardStep.SelectFile -> backupDestination != null - ImportWizardStep.MapColumns -> columns.any { it.selectedType != null } - ImportWizardStep.SelectVault -> - if (creatingNewVault) newVaultNameValid else selectedVaultId != null + ImportWizardStep.ProvidePassphrase -> passphraseValid + } - ImportWizardStep.ProvidePassphrase -> passphraseValid - } + val showContinueButton: Boolean + get() = progress == null - val showContinueButton: Boolean = progress == null + val backEnabled: Boolean + get() = progress == null || progress is ImportProgress.Failed - /** - * A function rather than a computed property: the new-vault name lives in a [TextFieldState], - * and a `val` would capture whatever it held when this state object was built rather than what - * the user has typed since. - */ fun resolveTarget(newVaultName: String): ImportTarget? = if (creatingNewVault) newVaultName.trim().takeIf(String::isNotBlank) ?.let { ImportTarget.New(it, newVaultIcon) } diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index a58f2a147..b3c207ff9 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -100,6 +100,7 @@ Back Couldn\'t read the selected file. The selected file is empty. + KeyGo imports .json and .csv files. Pick a file with one of those extensions. There was nothing to import in this backup. This file isn\'t a valid KeyGo backup. Your vault is locked. Unlock it and try again. diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt index 64826f8b3..bd7f559dc 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -36,12 +36,14 @@ import de.davisalessandro.keygo.rust.CsvImportResult import de.davisalessandro.keygo.rust.FieldConfidence import de.davisalessandro.keygo.rust.ImportReport import de.davisalessandro.keygo.rust.JsonEncryption +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlin.test.AfterTest @@ -115,6 +117,12 @@ class ImportWizardViewModelTest { fileName = "keygo.csv", ) + private fun textDestination() = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + fileName = "notes.txt", + ) + private fun csvAnalysis() = CsvAnalysis( columns = listOf( CsvColumn(0u, "name", listOf("Email")), @@ -246,6 +254,21 @@ class ImportWizardViewModelTest { assertTrue(csv.importCalls.isEmpty()) // not imported yet } + @Test + fun `Continue on an unsupported file reports the format error`() = runTest { + // The picker offers the wildcard MIME type, so a provider can hand back a .txt. + val viewModel = viewModel(FakeBackupDestinationResolver(result = textDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/notes.txt")) + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + + assertEquals( + ImportProgress.Failed(ImportError.UnsupportedFormat), + viewModel.state.value.progress, + ) + } + @Test fun `ChangeColumnType updates the selected type`() = runTest { fileStore.contents = "name,secret\nEmail,s3cr3t\n" @@ -565,4 +588,170 @@ class ImportWizardViewModelTest { assertEquals(ImportWizardStep.SelectFile, viewModel.state.value.step) } + + @Test + fun `seeding a CSV skips the file step and lands on the mapping`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + + viewModel.seedFile(BackupDestinationUri("content://doc/keygo.csv")) + val state = viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + assertTrue(state.fileChosenByHost) + assertNull(state.progress) + assertEquals(listOf(ImportWizardStep.MapColumns), state.steps) + } + + @Test + fun `seeding an ARK sealed JSON imports without asking anything`() = runTest { + json.inspectResult = JsonEncryption.ARK + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + + viewModel.seedFile(BackupDestinationUri("content://doc/keygo.json")) + val state = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + assertEquals(1, assertIs(state.progress).summary.imported) + } + + @Test + fun `seeding the same file again does not restart the import`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val uri = BackupDestinationUri("content://doc/keygo.csv") + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.seedFile(uri) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Username)) + + viewModel.seedFile(uri) + advanceUntilIdle() + + // A configuration change re-runs the seeding effect. The mapping in progress has to survive. + assertEquals(ImportWizardStep.MapColumns, viewModel.state.value.step) + assertEquals(CsvColumnType.Username, viewModel.state.value.columns[1].selectedType) + } + + @Test + fun `back from the first seeded step hands control to the host`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.seedFile(BackupDestinationUri("content://doc/keygo.csv")) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + viewModel.onEvent(ImportWizardUiEvent.Back) + + assertEquals(ImportWizardEvent.Exit, viewModel.event.first()) + } + + @Test + fun `the same file can be seeded again after handing control back`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val uri = BackupDestinationUri("content://doc/keygo.csv") + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.seedFile(uri) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + // Edit the mapping so a no-op second seed and a real one are distinguishable: a real one + // re-analyzes the file and throws this edit away, a no-op leaves it exactly as it is. + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Username)) + viewModel.onEvent(ImportWizardUiEvent.Back) + viewModel.event.first() + + viewModel.seedFile(uri) + val state = viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + // Back hands control back, and exit() resets the step to SelectFile, so arriving at + // MapColumns again already means a second seed ran. The analyzer count and the mapping + // reverting to its freshly suggested value instead of the edit above pin down that it + // re-read the file rather than restoring a step. + assertEquals(2, csv.analyzeCalls.size) + assertEquals(CsvColumnType.Password, state.columns[1].selectedType) + } + + @Test + fun `seeding a file KeyGo cannot read reports the format`() = runTest { + val viewModel = viewModel(FakeBackupDestinationResolver(result = textDestination())) + + viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) + val state = viewModel.state.first { it.progress is ImportProgress.Failed } + + assertEquals(ImportProgress.Failed(ImportError.UnsupportedFormat), state.progress) + } + + @Test + fun `exiting after an unsupported file error resets the wizard to a fresh state`() = runTest { + val viewModel = viewModel(FakeBackupDestinationResolver(result = textDestination())) + viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) + viewModel.state.first { it.progress is ImportProgress.Failed } + + viewModel.onEvent(ImportWizardUiEvent.Back) + viewModel.event.first() + + // This ViewModel is scoped to the host's back stack entry, so it outlives the visit. The + // gap between handing control back and the host seeding a new file is exactly what a second + // entry into the wizard would render if exit() left the dismissed error behind. + val state = viewModel.state.value + assertNull(state.progress) + assertEquals(ImportWizardStep.SelectFile, state.step) + assertFalse(state.fileChosenByHost) + assertNull(state.backupDestination) + assertNull(state.uri) + } + + @Test + fun `seeding a different file after backing out of a mapping does not carry over the old file's state`() = + runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val resolver = FakeBackupDestinationResolver(result = csvDestination()) + val viewModel = viewModel(resolver) + viewModel.seedFile(BackupDestinationUri("content://doc/keygo.csv")) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + viewModel.onEvent(ImportWizardUiEvent.Back) + viewModel.event.first() + + // Same gap as above, but from a mapping rather than an error: the previous file's + // column names are the tell if exit() left them behind. + val handedBackState = viewModel.state.value + assertEquals(ImportWizardStep.SelectFile, handedBackState.step) + assertEquals(emptyList(), handedBackState.columns) + assertFalse(handedBackState.fileChosenByHost) + + resolver.result = jsonDestination() + json.inspectResult = JsonEncryption.ARK + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) + viewModel.seedFile(BackupDestinationUri("content://doc/keygo.json")) + val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + assertEquals(1, assertIs(finalState.progress).summary.imported) + assertEquals(emptyList(), finalState.columns) + } + + @Test + fun `seedFile resolves the destination without a concurrent state write re-running it`() = runTest { + val gate = CompletableDeferred() + val resolver = FakeBackupDestinationResolver(result = textDestination(), gate = gate) + val viewModel = viewModel(resolver) + + viewModel.seedFile(BackupDestinationUri("content://doc/notes.txt")) + // seedFile's coroutine has called resolve() and is now parked on the gate, mid-CAS-lambda. + runCurrent() + + // A state write that lands while that lambda is still suspended: this is the same window + // the passphraseState.clearText() collector writes into in the real flow. If resolve() were + // still called from inside _state.update, the CAS retry this forces would call it again. + env.vaultRepo.seed(testVault(name = "Personal")) + runCurrent() + + gate.complete(Unit) + viewModel.state.first { it.backupDestination != null } + + assertEquals(listOf(BackupDestinationUri("content://doc/notes.txt")), resolver.calls) + } } diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt index cf491f46e..448ea54d0 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt @@ -2,11 +2,12 @@ package de.davis.keygo.feature.backup.presentation.import.model import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ImportWizardStepTest { @Test - fun `SelectFile shows a single step`() { + fun `the file step alone is the whole lane when the wizard owns it`() { assertEquals( listOf(ImportWizardStep.SelectFile), importStepsFor(ImportWizardStep.SelectFile), @@ -14,15 +15,15 @@ class ImportWizardStepTest { } @Test - fun `ProvidePassphrase reveals the passphrase step`() { + fun `csv lane walks the file step first`() { assertEquals( - listOf(ImportWizardStep.SelectFile, ImportWizardStep.ProvidePassphrase), - importStepsFor(ImportWizardStep.ProvidePassphrase), + listOf(ImportWizardStep.SelectFile, ImportWizardStep.MapColumns), + importStepsFor(ImportWizardStep.MapColumns), ) } @Test - fun `SelectVault reveals the file, mapping and vault steps`() { + fun `csv lane walks all three steps by the time it reaches select vault`() { assertEquals( listOf( ImportWizardStep.SelectFile, @@ -32,4 +33,80 @@ class ImportWizardStepTest { importStepsFor(ImportWizardStep.SelectVault), ) } + + @Test + fun `json lane walks the file step first`() { + assertEquals( + listOf(ImportWizardStep.SelectFile, ImportWizardStep.ProvidePassphrase), + importStepsFor(ImportWizardStep.ProvidePassphrase), + ) + } + + @Test + fun `csv lane drops the file step when the host owns it`() { + assertEquals( + listOf(ImportWizardStep.MapColumns, ImportWizardStep.SelectVault), + importStepsFor(ImportWizardStep.SelectVault, includeFileStep = false), + ) + } + + @Test + fun `json lane drops the file step when the host owns it`() { + assertEquals( + listOf(ImportWizardStep.ProvidePassphrase), + importStepsFor(ImportWizardStep.ProvidePassphrase, includeFileStep = false), + ) + } + + @Test + fun `a host owned lane standing on the file step has walked nothing`() { + assertEquals( + emptyList(), + importStepsFor(ImportWizardStep.SelectFile, includeFileStep = false), + ) + } + + @Test + fun `map columns goes back to the file step when the wizard owns it`() { + assertEquals( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns.previousStep(fileChosenByHost = false), + ) + } + + @Test + fun `map columns hands back to the host when the host owns the file`() { + assertNull(ImportWizardStep.MapColumns.previousStep(fileChosenByHost = true)) + } + + @Test + fun `provide passphrase goes back to the file step when the wizard owns it`() { + assertEquals( + ImportWizardStep.SelectFile, + ImportWizardStep.ProvidePassphrase.previousStep(fileChosenByHost = false), + ) + } + + @Test + fun `provide passphrase hands back to the host when the host owns the file`() { + assertNull(ImportWizardStep.ProvidePassphrase.previousStep(fileChosenByHost = true)) + } + + @Test + fun `select vault always goes back to map columns`() { + assertEquals( + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault.previousStep(fileChosenByHost = true), + ) + assertEquals( + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault.previousStep(fileChosenByHost = false), + ) + } + + @Test + fun `the file step has nothing to go back to`() { + assertNull(ImportWizardStep.SelectFile.previousStep(fileChosenByHost = false)) + } + } diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt index c3a1df867..79795cd72 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt @@ -3,6 +3,9 @@ package de.davis.keygo.feature.backup.presentation.import.model import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportSummary import de.davis.keygo.feature.backup.domain.model.ImportTarget import kotlin.test.Test import kotlin.test.assertEquals @@ -106,4 +109,34 @@ class ImportWizardUiStateTest { assertEquals("my.passwords.backup", state.suggestedVaultName) } + + @Test + fun `back is live while the user is still answering questions`() { + assertTrue(ImportWizardUiState(progress = null).backEnabled) + } + + @Test + fun `back is live after a failure`() { + val progress = ImportProgress.Failed(ImportError.NothingImported) + + assertTrue(ImportWizardUiState(progress = progress).backEnabled) + } + + @Test + fun `back is inert while the import runs`() { + assertFalse(ImportWizardUiState(progress = ImportProgress.Reading).backEnabled) + assertFalse(ImportWizardUiState(progress = ImportProgress.Parsing).backEnabled) + assertFalse( + ImportWizardUiState( + progress = ImportProgress.Running(processed = 1, total = 10), + ).backEnabled, + ) + } + + @Test + fun `back is inert on the summary`() { + val summary = ImportSummary(imported = 1, skipped = 0, failed = 0, vaultsCreated = 1) + + assertFalse(ImportWizardUiState(progress = ImportProgress.Succeeded(summary)).backEnabled) + } } diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt index 7cae396c4..9a7812430 100644 --- a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt @@ -1,25 +1,36 @@ package de.davis.keygo.feature.backup.data +import de.davis.keygo.feature.backup.FakeBackupScheduler import de.davis.keygo.feature.backup.domain.BackupDestinationResolver import de.davis.keygo.feature.backup.domain.model.BackupDestination import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import kotlinx.coroutines.CompletableDeferred +/** + * Records every resolution and hands back [result]. Pass [gate] to park inside [resolve] until the + * test completes it - mirroring [FakeBackupScheduler] - so a test can hold a resolution open and + * observe whether anything re-enters it. + */ class FakeBackupDestinationResolver( var result: BackupDestination = BackupDestination( provider = BackupDestination.Provider.OnDevice, displayPath = "Internal storage/Backups", ), + private val gate: CompletableDeferred? = null, ) : BackupDestinationResolver { - var lastUri: BackupDestinationUri? = null + val calls = mutableListOf() + + val lastUri: BackupDestinationUri? get() = calls.lastOrNull() var lastCachedName: String? = null override suspend fun resolve( uri: BackupDestinationUri, cachedName: String?, ): BackupDestination { - lastUri = uri + calls += uri lastCachedName = cachedName + gate?.await() return result } } diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 000000000..98ffcb9ba --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.feature.onboarding" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } +} + +dependencies { + implementation(projects.core.ui) + implementation(projects.core.item) + implementation(projects.core.identity) + implementation(projects.feature.backup) + implementation(projects.feature.autofill) + implementation(projects.migration.createAccess) + + implementation(libs.androidx.navigation.compose) +} diff --git a/feature/onboarding/consumer-rules.pro b/feature/onboarding/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/di/FeatureOnboardingModule.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/di/FeatureOnboardingModule.kt new file mode 100644 index 000000000..8e9c1eee0 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/di/FeatureOnboardingModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.onboarding.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.onboarding") +object FeatureOnboardingModule diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt new file mode 100644 index 000000000..226f560b9 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableAutofillContent.kt @@ -0,0 +1,156 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold +import de.davis.keygo.feature.onboarding.presentation.component.SmallIconContainer +import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupStatus +import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupStep +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState +import de.davis.keygo.feature.onboarding.presentation.model.setupSteps + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun EnableAutofillContent(state: OnboardingUiState.EnableAutofill) { + val steps = state.setupSteps() + + OnboardingScaffold( + iconContainer = { + SmallIconContainer( + shape = MaterialShapes.Cookie7Sided.toShape() + ) { + Icon( + imageVector = Icons.Default.AutoFixHigh, + contentDescription = null + ) + } + }, + title = stringResource(R.string.autofill_title), + description = stringResource(R.string.autofill_subtitle), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + steps.forEachIndexed { index, (step, status) -> + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index, steps.size), + leadingContent = { + StepBadge( + number = index + 1, + status = status, + ) + }, + supportingContent = { + Text(text = stringResource(step.supportingRes)) + } + ) { + Text(text = stringResource(step.titleRes)) + } + } + } + } +} + +@Composable +internal fun StepBadge( + number: Int, + status: AutofillSetupStatus, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val containerColor = when (status) { + AutofillSetupStatus.Current -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primaryContainer + } + val contentColor = when (status) { + AutofillSetupStatus.Current -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onPrimaryContainer + } + + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(containerColor), + contentAlignment = Alignment.Center, + ) { + if (status == AutofillSetupStatus.Done) + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = contentColor, + ) + else Text( + text = number.toString(), + color = contentColor, + style = MaterialTheme.typography.headlineSmallEmphasized, + ) + } +} + +private val AutofillSetupStep.titleRes + get() = when (this) { + AutofillSetupStep.OpenSystemSettings -> R.string.autofill_open_settings + AutofillSetupStep.ChooseKeyGo -> R.string.autofill_choose_keygo + AutofillSetupStep.EnableInChrome -> R.string.autofill_chrome + } + +private val AutofillSetupStep.supportingRes + get() = when (this) { + AutofillSetupStep.OpenSystemSettings -> R.string.autofill_open_settings_support + AutofillSetupStep.ChooseKeyGo -> R.string.autofill_choose_keygo_support + AutofillSetupStep.EnableInChrome -> R.string.autofill_chrome_support + } + +@Preview +@Composable +private fun EnableAutofillContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EnableAutofillContent( + state = OnboardingUiState.EnableAutofill(chromeAvailable = true) + ) + } + } +} + +@Preview +@Composable +private fun EnableAutofillContentChromePendingPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EnableAutofillContent( + state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = true, + ) + ) + } + } +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableBiometricsContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableBiometricsContent.kt new file mode 100644 index 000000000..9810dad19 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/EnableBiometricsContent.kt @@ -0,0 +1,51 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.component.LargeIconContainer +import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun EnableBiometricsContent() { + OnboardingScaffold( + iconContainer = { + LargeIconContainer( + shape = MaterialShapes.Square.toShape() + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null + ) + } + }, + title = stringResource(R.string.biometrics_title), + description = stringResource(R.string.biometrics_subtitle), + contentHorizontalAlignment = Alignment.CenterHorizontally + ) { + // No content for Enable Biometrics + } +} + +@Preview +@Composable +private fun EnableBiometricsContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EnableBiometricsContent() + } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/ImportVaultContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/ImportVaultContent.kt new file mode 100644 index 000000000..324da115c --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/ImportVaultContent.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.feature.backup.presentation.component.BackupFileChooserCard +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold +import de.davis.keygo.feature.onboarding.presentation.component.SmallIconContainer +import de.davis.keygo.feature.backup.R as BackupR + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ImportVaultContent(onChooseFile: () -> Unit) { + OnboardingScaffold( + iconContainer = { + SmallIconContainer( + shape = MaterialShapes.Pentagon.toShape() + ) { + Icon( + imageVector = Icons.Default.ImportExport, + contentDescription = null + ) + } + }, + title = stringResource(R.string.import_title), + description = stringResource(R.string.import_subtitle), + ) { + BackupFileChooserCard( + icon = Icons.Default.FileOpen, + title = stringResource(BackupR.string.import_choose_title), + subtitle = stringResource(BackupR.string.import_choose_subtitle), + action = stringResource(BackupR.string.import_choose_action), + onChoose = onChooseFile, + ) + } +} + +@Preview +@Composable +private fun ImportVaultContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ImportVaultContent(onChooseFile = {}) + } + } +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/MainPasswordContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/MainPasswordContent.kt new file mode 100644 index 000000000..266b444c9 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/MainPasswordContent.kt @@ -0,0 +1,156 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.core.ui.model.error +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold +import de.davis.keygo.feature.onboarding.presentation.component.SmallIconContainer +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun MainPasswordContent( + state: OnboardingUiState.SetMainPassword, +) { + OnboardingScaffold( + iconContainer = { + SmallIconContainer( + shape = MaterialShapes.Diamond.toShape() + ) { + Icon( + imageVector = Icons.Default.Password, + contentDescription = null + ) + } + }, + title = stringResource(R.string.main_password_title), + description = stringResource(R.string.main_password_subtitle), + info = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = stringResource(R.string.main_password_info), + style = MaterialTheme.typography.bodySmall, + ) + } + } + ) { + var forceCompact by rememberSaveable { mutableStateOf(false) } + var passwordHidden by remember { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.passwordTextFieldState, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + forceCompact = !it.hasFocus + }, + label = { Text(text = stringResource(R.string.main_password_label)) }, + textObfuscationMode = if (passwordHidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton( + isHidden = passwordHidden, + onClick = { passwordHidden = !passwordHidden } + ) + }, + isError = state.passwordError != null, + supportingText = state.passwordError?.let { + { Text(text = it.error) } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password + ) + ) + + StrengthIndicator( + passwordScore = state.passwordScore, + forceCompact = forceCompact, + ) + + var confirmPasswordHidden by remember { mutableStateOf(true) } + OutlinedSecureTextField( + state = state.confirmPasswordTextFieldState, + modifier = Modifier.fillMaxWidth(), + label = { Text(text = stringResource(R.string.confirm_main_password_label)) }, + textObfuscationMode = if (confirmPasswordHidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton( + isHidden = confirmPasswordHidden, + onClick = { confirmPasswordHidden = !confirmPasswordHidden } + ) + }, + isError = state.confirmPasswordError != null, + supportingText = state.confirmPasswordError?.let { + { Text(text = it.error) } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +private fun MainPasswordContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + MainPasswordContent( + state = OnboardingUiState.SetMainPassword( + passwordTextFieldState = remember { TextFieldState() }, + confirmPasswordTextFieldState = remember { TextFieldState() }, + passwordScore = PasswordScore.Moderate + ), + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt new file mode 100644 index 000000000..70843faeb --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingGraph.kt @@ -0,0 +1,36 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import de.davis.keygo.core.ui.RouteDestination +import de.davis.keygo.core.ui.model.PendingTotpImport +import kotlinx.serialization.Serializable + + +fun NavGraphBuilder.onboardingGraph(onSuccess: (String?) -> Unit) { + composable { s -> + OnboardingScreen( + onSuccess = { + onSuccess(s.toRoute().uri) + } + ) + } +} + +/** + * The pending import travels as primitives, not as a [PendingTotpImport] field. Type-safe + * navigation has no [androidx.navigation.NavType] for a custom class unless one is supplied + * through a typeMap, and building the graph without it throws while the graph is created. + */ +@Serializable +data class OnboardingRoute( + val totpInfo: String? = null, + val queries: String? = null, +) : RouteDestination { + val pendingTotpImport: PendingTotpImport + get() = PendingTotpImport(totpInfo, queries) + + val uri: String? + get() = pendingTotpImport.uri +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt new file mode 100644 index 000000000..851bb037a --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingScreen.kt @@ -0,0 +1,362 @@ +package de.davis.keygo.feature.onboarding.presentation + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.backup.presentation.import.ImportWizardScreen +import de.davis.keygo.feature.backup.presentation.import.rememberImportFilePicker +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupAction +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingStepProgress +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState +import org.koin.androidx.compose.koinViewModel + +private const val TAG = "OnboardingScreen" +private val OnboardingMaxWidth = 480.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnboardingScreen(onSuccess: () -> Unit) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val stepProgress by viewModel.stepProgress.collectAsStateWithLifecycle() + + val loading by viewModel.loading.collectAsStateWithLifecycle() + + BackHandler(enabled = stepProgress.canGoBack) { + viewModel.onPreviousStep() + } + + val biometricCryptoController = rememberBiometricCryptoController() + ObserveAsEvents(viewModel.biometricFlow) { + biometricCryptoController.requestCipher( + keyId = KeyId.BiometricVaultKek, + mode = CryptographicMode.Wrap + ).onSuccess { + viewModel.performCreateAccess(it) + }.onFailure { + Log.e("OnboardingScreen", "Failed to create cipher for biometric access: $it") + viewModel.performCreateAccess() //TODO: maybe show error msg to user + } + } + + ObserveAsEvents(viewModel.finishedFlow) { + onSuccess() + } + + val context = LocalContext.current + val autofillPickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + + ObserveAsEvents(viewModel.autofillPickerFlow) { + try { + autofillPickerLauncher.launch( + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = "package:${context.packageName}".toUri() + } + ) + } catch (e: ActivityNotFoundException) { + // Some AOSP builds, Android TV, and a few OEM ROMs have nothing that resolves this + // intent. The user still has the "Finish setup" button to move past the step, so + // failing quietly here is acceptable as long as it stays diagnosable. + Log.w(TAG, "No activity found to handle the system autofill picker", e) + } + } + + // The picker reports nothing back and Chrome's hand off is not a result flow at all, so the + // resume read is what actually learns the new state. Keying on the step also refreshes on + // arrival, and keeps Chrome's cross process query off every other step's resume. + val onAutofillStep = state is OnboardingUiState.EnableAutofill + LifecycleResumeEffect(onAutofillStep) { + if (onAutofillStep) viewModel.refreshAutofillState() + onPauseOrDispose {} + } + + val chooseImportFile = rememberImportFilePicker(viewModel::onImportFileChosen) + + // The wizard brings its own Scaffold, top bar, step indicator and continue button, so it + // replaces the step chrome rather than rendering inside it. It also owns back for every one of + // its phases while it holds a preselected file, so onboarding adds no handler of its own. + val importFile = (state as? OnboardingUiState.ImportData)?.fileUri + if (importFile != null) ImportWizardScreen( + preselectedFile = importFile, + navigateUp = viewModel::onImportCancelled, + onFinished = viewModel::onImportFinished, + ) + else OnboardingSteps( + state = state, + stepProgress = stepProgress, + loading = loading, + onNextStep = viewModel::onNextStep, + onPreviousStep = viewModel::onPreviousStep, + onSkip = viewModel::onSkip, + onChooseImportFile = chooseImportFile::launch, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun OnboardingSteps( + state: OnboardingUiState, + stepProgress: OnboardingStepProgress, + loading: Boolean, + onNextStep: () -> Unit, + onPreviousStep: () -> Unit, + onSkip: () -> Unit, + onChooseImportFile: () -> Unit, +) { + val contentHeight = ButtonDefaults.LargeContainerHeight + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(stepProgress.totalSteps) { index -> + val color by animateColorAsState( + targetValue = if (index <= stepProgress.currentIndex) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primaryContainer, + label = "onboarding_step_indicator", + ) + Box( + modifier = Modifier + .height(8.dp) + .weight(1f) + .clip(CircleShape) + .background(color), + ) + } + } + }, + navigationIcon = { + val effectsSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + val spatialSpec = MaterialTheme.motionScheme.defaultSpatialSpec() + AnimatedVisibility( + visible = stepProgress.canGoBack, + exit = fadeOut(effectsSpec) + shrinkHorizontally( + animationSpec = spatialSpec, + shrinkTowards = Alignment.Start, + ), + enter = fadeIn(effectsSpec) + expandHorizontally( + animationSpec = spatialSpec, + expandFrom = Alignment.Start, + ) + ) { + IconButton( + onClick = onPreviousStep + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + } + } + ) + }, + bottomBar = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(top = 8.dp) + .widthIn(max = OnboardingMaxWidth), + ) { + state.optionalActionText?.let { + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = it) + } + } + + if (state.isOutlinedButtonCandidate()) + OutlinedButton( + onClick = onNextStep, + shapes = ButtonDefaults.shapesFor(contentHeight), + modifier = Modifier + .sizeIn(minHeight = contentHeight) + .fillMaxWidth(), + contentPadding = ButtonDefaults.contentPaddingFor(contentHeight) + ) { + Text( + text = state.buttonText, + style = ButtonDefaults.textStyleFor(contentHeight), + ) + } + else Button( + onClick = onNextStep, + shapes = ButtonDefaults.shapesFor(contentHeight), + modifier = Modifier + .sizeIn(minHeight = contentHeight) + .fillMaxWidth(), + contentPadding = ButtonDefaults.contentPaddingFor(contentHeight) + ) { + Text( + text = state.buttonText, + style = ButtonDefaults.textStyleFor(contentHeight), + ) + } + } + } + } + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + AnimatedContent( + targetState = state, + contentKey = { it::class }, + modifier = Modifier.widthIn(max = OnboardingMaxWidth), + ) { state -> + when (state) { + is OnboardingUiState.Welcome -> WelcomeContent(state = state) + + is OnboardingUiState.SetMainPassword -> MainPasswordContent(state = state) + + OnboardingUiState.EnableBiometrics -> EnableBiometricsContent() + is OnboardingUiState.ImportData -> ImportVaultContent( + onChooseFile = onChooseImportFile, + ) + + is OnboardingUiState.EnableAutofill -> EnableAutofillContent(state = state) + } + } + } + } + + if (loading) + BasicAlertDialog( + onDismissRequest = {} + ) { + Box( + contentAlignment = Alignment.Center + ) { + ContainedLoadingIndicator() + } + } +} + +private val OnboardingUiState.buttonText: String + @Composable + get() = stringResource( + when (this) { + is OnboardingUiState.Welcome -> R.string.get_started + is OnboardingUiState.SetMainPassword -> R.string.continue_text + OnboardingUiState.EnableBiometrics -> R.string.enable_biometrics + is OnboardingUiState.ImportData -> R.string.skip_for_now + is OnboardingUiState.EnableAutofill -> when (nextAction) { + AutofillSetupAction.OpenSystemSettings -> R.string.autofill_open_settings + AutofillSetupAction.OpenChromeSettings -> R.string.autofill_enable_in_chrome + AutofillSetupAction.Finish -> R.string.finish_setup + } + } + ) + + +private val OnboardingUiState.optionalActionText: String? + @Composable + get() = when (this) { + is OnboardingUiState.Welcome, + is OnboardingUiState.ImportData, + is OnboardingUiState.SetMainPassword -> null + + OnboardingUiState.EnableBiometrics -> R.string.skip_for_now + is OnboardingUiState.EnableAutofill -> + R.string.finish_setup.takeIf { nextAction != AutofillSetupAction.Finish } + }?.let { stringResource(it) } + +private fun OnboardingUiState.isOutlinedButtonCandidate() = this is OnboardingUiState.ImportData + +@Preview +@PreviewScreenSizes +@Composable +private fun OnboardingStepsPreview() { + MaterialTheme { + OnboardingSteps( + state = OnboardingUiState.Welcome(), + stepProgress = OnboardingStepProgress( + currentIndex = 0, + totalSteps = 5, + canGoBack = false, + ), + loading = false, + onNextStep = {}, + onPreviousStep = {}, + onSkip = {}, + onChooseImportFile = {}, + ) + } +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt new file mode 100644 index 000000000..594007ab4 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/OnboardingViewModel.kt @@ -0,0 +1,299 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.onboarding.presentation.model.AutofillSetupAction +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingStep +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingStepProgress +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import javax.crypto.Cipher +import kotlin.time.Duration.Companion.milliseconds + +@KoinViewModel +internal class OnboardingViewModel( + savedStateHandle: SavedStateHandle, + private val biometricAvailabilityRepository: BiometricAvailabilityRepository, + private val autofillServiceRepository: AutofillServiceRepository, + private val chromeAutofillRepository: ChromeAutofillRepository, + + private val passwordStrengthEstimator: PasswordStrengthEstimator, + private val createAccess: CreateAccessUseCase, +) : ViewModel() { + + private val hasPendingTotpImport = savedStateHandle.toRoute().uri != null + + private val stepsToSkip = MutableStateFlow>(emptySet()) + + private fun calculateStepsToSkip() { + viewModelScope.launch { + val autofill = readAutofillState() + _enableAutofillState.update { autofill } + + val skipSteps = buildSet { + if (!biometricAvailabilityRepository.availability()) add(OnboardingStep.EnableBiometrics) + // Not "both enabled": on a device with no Chrome the Chrome read is false forever, + // which would keep offering a step that has nothing left to do. + if (autofill.nextAction == AutofillSetupAction.Finish) + add(OnboardingStep.EnableAutofillService) + } + stepsToSkip.update { skipSteps } + } + } + + private suspend fun readAutofillState(): OnboardingUiState.EnableAutofill { + val chromeAvailable = chromeAutofillRepository.isAvailable() + return OnboardingUiState.EnableAutofill( + systemAutofillEnabled = autofillServiceRepository.isEnabled(), + chromeAvailable = chromeAvailable, + chromeAutofillEnabled = chromeAvailable && chromeAutofillRepository.isAutofillEnabled(), + ) + } + + fun refreshAutofillState() { + viewModelScope.launch { + val autofill = readAutofillState() + _enableAutofillState.update { autofill } + } + } + + private val passwordTextFieldState = TextFieldState() + private val confirmPasswordTextFieldState = TextFieldState() + + private val _enableBiometricsState = MutableStateFlow( + OnboardingUiState.EnableBiometrics + ) + + private val _importDataState = MutableStateFlow( + OnboardingUiState.ImportData() + ) + + private val _enableAutofillState = MutableStateFlow( + OnboardingUiState.EnableAutofill() + ) + + // Declared after every property calculateStepsToSkip() touches. init runs in declaration + // order, and viewModelScope is Main.immediate, so on the main thread this launch body starts + // executing synchronously; if it ran before _enableAutofillState above it would read a + // not-yet-initialized property. + init { + calculateStepsToSkip() + } + + private val biometricChannel = Channel(Channel.BUFFERED) + val biometricFlow = biometricChannel.receiveAsFlow() + + private val autofillPickerChannel = Channel(Channel.BUFFERED) + val autofillPickerFlow = autofillPickerChannel.receiveAsFlow() + + private val finishedChannel = Channel(Channel.BUFFERED) + val finishedFlow = finishedChannel.receiveAsFlow() + + private val _loading = MutableStateFlow(false) + val loading = _loading.asStateFlow() + + private val _passwordError = MutableStateFlow(null) + private val _confirmPasswordError = MutableStateFlow(null) + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private val passwordScoreFlow = snapshotFlow { passwordTextFieldState.text } + .debounce(150.milliseconds) + .mapLatest { passwordStrengthEstimator(it.toString()) } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + + private val _mainPasswordState = combine( + snapshotFlow { passwordTextFieldState.text }, + snapshotFlow { confirmPasswordTextFieldState.text }, + passwordScoreFlow, + _passwordError, + _confirmPasswordError + ) { pwd, confirm, score, manualPwdError, manualConfirmError -> + // Automatically clear manual errors if the user has fixed them by typing + val resolvedPwdError = if (pwd.isNotBlank()) null else manualPwdError + val resolvedConfirmError = if (pwd == confirm) null else manualConfirmError + + OnboardingUiState.SetMainPassword( + passwordTextFieldState = passwordTextFieldState, + confirmPasswordTextFieldState = confirmPasswordTextFieldState, + passwordScore = score, + passwordError = resolvedPwdError, + confirmPasswordError = resolvedConfirmError + ) + } + + private val _step = MutableStateFlow(OnboardingStep.Welcome) + + @OptIn(ExperimentalCoroutinesApi::class) + val state = _step.flatMapLatest { + when (it) { + OnboardingStep.Welcome -> flowOf(OnboardingUiState.Welcome(pendingTotpImport = hasPendingTotpImport)) + + OnboardingStep.SetMainPassword -> _mainPasswordState + OnboardingStep.EnableBiometrics -> _enableBiometricsState + OnboardingStep.ImportExistingData -> _importDataState + OnboardingStep.EnableAutofillService -> _enableAutofillState + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = OnboardingUiState.Welcome() + ) + + val stepProgress = combine(_step, stepsToSkip) { step, skip -> + val activeSteps = OnboardingStep.activeSteps(skip) + OnboardingStepProgress( + currentIndex = activeSteps.indexOf(step), + totalSteps = activeSteps.size, + canGoBack = step.canGoBack, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = OnboardingStepProgress( + currentIndex = 0, + totalSteps = OnboardingStep.entries.size, + canGoBack = false, + ), + ) + + fun onPreviousStep() { + val previous = _step.value.previousStep(stepsToSkip.value) ?: return + + // fileUri survives onImportFinished by design (see its own comment), so stepping back + // from autofill into a completed import would otherwise reopen the wizard on the old + // file instead of the chooser. Only autofill's back reaches this step, so this never + // fires while the chooser itself is still on screen. + if (previous == OnboardingStep.ImportExistingData) + _importDataState.update { it.copy(fileUri = null) } + + _step.update { previous } + } + + fun onNextStep() { + when (_step.value) { + OnboardingStep.SetMainPassword -> { + val password = passwordTextFieldState.text.toString() + val confirmPassword = confirmPasswordTextFieldState.text.toString() + + if (password.isBlank()) { + _passwordError.update { UiFieldError.Empty } + return + } + + if (password != confirmPassword) { + _confirmPasswordError.update { UiFieldError.Mismatch } + return + } + + if (OnboardingStep.EnableBiometrics in stepsToSkip.value) return performCreateAccess() + } + + OnboardingStep.EnableBiometrics -> { + biometricChannel.trySend(Unit) + return // wait for biometric result before proceeding to next step + } + + OnboardingStep.EnableAutofillService -> when (_enableAutofillState.value.nextAction) { + AutofillSetupAction.OpenSystemSettings -> { + autofillPickerChannel.trySend(Unit) + return // wait for the user to come back from the system picker + } + + AutofillSetupAction.OpenChromeSettings -> { + chromeAutofillRepository.openChromeAutofillSettings() + return // wait for the user to come back from Chrome + } + + // Nothing left to set up, fall through to the step advance below. + AutofillSetupAction.Finish -> {} + } + + else -> {} + } + + internalSkip() + } + + fun performCreateAccess(cipher: Cipher? = null) { + viewModelScope.launch { + loading { + createAccess( + password = passwordTextFieldState.text.toString(), + biometricCipher = cipher + ).onSuccess { + internalSkip() + } + } + } + } + + fun onSkip() { + if (_step.value == OnboardingStep.EnableBiometrics) return performCreateAccess() + + internalSkip() + } + + fun onImportFileChosen(uri: BackupDestinationUri) = + _importDataState.update { it.copy(fileUri = uri) } + + fun onImportCancelled() = _importDataState.update { it.copy(fileUri = null) } + + /** + * The wizard reports its own outcome, so onboarding only has to move on. + * + * Deliberately does not clear `fileUri` first. `state` derives from `_step.flatMapLatest`, so + * advancing `_step` here is what swaps `state` straight to the next step's flow. Clearing + * `fileUri` before that swap would emit `ImportData(null)` while `_step` still pointed at + * `ImportExistingData`, flashing the chooser card between the summary and the next step. + */ + fun onImportFinished() = internalSkip() + + private fun internalSkip() { + val nextStep = _step.value.nextStep(stepsToSkip.value) ?: return finishUp() + _step.update { nextStep } + } + + private fun finishUp() { + finishedChannel.trySend(Unit) + } + + private suspend fun loading(block: suspend () -> R): R { + _loading.update { true } + try { + return block() + } finally { + _loading.update { false } + } + } +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/WelcomeContent.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/WelcomeContent.kt new file mode 100644 index 000000000..60544754b --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/WelcomeContent.kt @@ -0,0 +1,60 @@ +package de.davis.keygo.feature.onboarding.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.feature.onboarding.R +import de.davis.keygo.feature.onboarding.presentation.component.LargeIconContainer +import de.davis.keygo.feature.onboarding.presentation.component.OnboardingScaffold +import de.davis.keygo.feature.onboarding.presentation.model.OnboardingUiState +import de.davis.keygo.core.ui.R as CoreUiR + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun WelcomeContent(state: OnboardingUiState.Welcome) { + OnboardingScaffold( + iconContainer = { + LargeIconContainer( + shape = MaterialShapes.Arrow.toShape() + ) { + Icon( + painter = painterResource(CoreUiR.drawable.ic_launcher_monochrome), + contentDescription = null + ) + } + }, + title = stringResource(if (state.pendingTotpImport) R.string.welcome_totp_import_title else R.string.welcome_title), + description = stringResource(if (state.pendingTotpImport) R.string.welcome_totp_import_subtitle else R.string.welcome_subtitle), + contentHorizontalAlignment = Alignment.CenterHorizontally + ) { + // No content for Welcome + } +} + +@Preview +@Composable +private fun WelcomeContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + WelcomeContent( + state = OnboardingUiState.Welcome() + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/component/OnboardingScaffold.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/component/OnboardingScaffold.kt new file mode 100644 index 000000000..af730f97c --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/component/OnboardingScaffold.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.onboarding.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +internal fun SmallIconContainer( + shape: Shape, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Surface( + modifier = modifier + .size(100.dp) + .aspectRatio(1f), + shape = shape, + color = MaterialTheme.colorScheme.primary, + ) { + Box( + modifier = Modifier.requiredSize(48.dp), + contentAlignment = Alignment.Center, + propagateMinConstraints = true, + content = content, + ) + } +} + +@Composable +internal fun LargeIconContainer( + shape: Shape, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Surface( + modifier = modifier + .size(250.dp) + .aspectRatio(1f), + shape = shape, + color = MaterialTheme.colorScheme.primary, + ) { + Box( + modifier = Modifier.requiredSize(175.dp), + contentAlignment = Alignment.Center, + propagateMinConstraints = true, + content = content, + ) + } +} + +private fun arrangementFor(horizontal: Alignment.Horizontal) = when (horizontal) { + Alignment.CenterHorizontally -> Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + else -> Arrangement.spacedBy(8.dp) +} + +private fun textAlignFor(horizontal: Alignment.Horizontal) = when (horizontal) { + Alignment.CenterHorizontally -> TextAlign.Center + else -> null +} + +@Composable +internal fun OnboardingScaffold( + iconContainer: @Composable () -> Unit, + title: String, + description: String, + contentHorizontalAlignment: Alignment.Horizontal = Alignment.Start, + info: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = arrangementFor(contentHorizontalAlignment), + horizontalAlignment = contentHorizontalAlignment, + ) { + iconContainer() + + Text( + text = title, + style = MaterialTheme.typography.headlineLargeEmphasized, + modifier = Modifier.fillMaxWidth(), + textAlign = textAlignFor(contentHorizontalAlignment), + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth(if (contentHorizontalAlignment == Alignment.CenterHorizontally) 0.75f else 1f), + textAlign = textAlignFor(contentHorizontalAlignment), + ) + + content() + } + + info?.invoke() + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt new file mode 100644 index 000000000..8c768a0b8 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetup.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +/** What the primary button on the autofill step does next. */ +internal enum class AutofillSetupAction { + OpenSystemSettings, + OpenChromeSettings, + Finish, +} + +/** One row of the instruction list on the autofill step. */ +internal enum class AutofillSetupStep { + OpenSystemSettings, + ChooseKeyGo, + EnableInChrome, +} + +internal enum class AutofillSetupStatus { + Done, + Current, + Upcoming, +} + +/** + * The rows to render, in order. Opening the picker and picking KeyGo are a single act for the user, + * so both follow the system service flag and flip to done together. The Chrome row is dropped + * entirely when there is no Chrome to act on. + */ +internal fun OnboardingUiState.EnableAutofill.setupSteps(): List> { + val steps = listOfNotNull( + AutofillSetupStep.OpenSystemSettings to systemAutofillEnabled, + AutofillSetupStep.ChooseKeyGo to systemAutofillEnabled, + (AutofillSetupStep.EnableInChrome to chromeAutofillEnabled).takeIf { chromeAvailable }, + ) + + val currentIndex = steps.indexOfFirst { (_, done) -> !done } + return steps.mapIndexed { index, (step, done) -> + step to when { + done -> AutofillSetupStatus.Done + index == currentIndex -> AutofillSetupStatus.Current + else -> AutofillSetupStatus.Upcoming + } + } +} diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStep.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStep.kt new file mode 100644 index 000000000..f58f71054 --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStep.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +internal enum class OnboardingStep { + Welcome, + SetMainPassword, + EnableBiometrics, + ImportExistingData, + EnableAutofillService; + + fun nextStep(skip: Set): OnboardingStep? { + val pool = activeSteps(skip) + val currentIndex = pool.indexOf(this) + return if (currentIndex != -1) pool.getOrNull(currentIndex + 1) else null + } + + fun previousStep(skip: Set): OnboardingStep? { + if (!canGoBack) return null + val pool = activeSteps(skip) + val currentIndex = pool.indexOf(this) + return if (currentIndex != -1) pool.getOrNull(currentIndex - 1) else null + } + + val canGoBack: Boolean + get() = this == SetMainPassword || this == EnableBiometrics || this == EnableAutofillService + + companion object { + fun activeSteps(skip: Set): List = + entries.filterNot { it in skip } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepProgress.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepProgress.kt new file mode 100644 index 000000000..8a5678aeb --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepProgress.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +internal data class OnboardingStepProgress( + val currentIndex: Int, + val totalSteps: Int, + val canGoBack: Boolean, +) diff --git a/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt new file mode 100644 index 000000000..c551a33ff --- /dev/null +++ b/feature/onboarding/src/main/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingUiState.kt @@ -0,0 +1,47 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +import androidx.compose.foundation.text.input.TextFieldState +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +internal sealed interface OnboardingUiState { + + data class Welcome(val pendingTotpImport: Boolean = false) : OnboardingUiState + + data class SetMainPassword( + val passwordTextFieldState: TextFieldState, + val confirmPasswordTextFieldState: TextFieldState, + val passwordScore: PasswordScore, + val passwordError: UiFieldError? = null, + val confirmPasswordError: UiFieldError? = null, + ) : OnboardingUiState + + data object EnableBiometrics : OnboardingUiState + + /** + * @param fileUri the file the user picked to import. While it is set the import wizard owns the + * screen, and clearing it returns to the chooser. + */ + data class ImportData(val fileUri: BackupDestinationUri? = null) : OnboardingUiState + + data class EnableAutofill( + val systemAutofillEnabled: Boolean = false, + val chromeAvailable: Boolean = false, + val chromeAutofillEnabled: Boolean = false, + ) : OnboardingUiState { + + /** + * Single source of truth for the primary button: the ViewModel reads it to decide what to + * do, the screen reads it to decide what to say. Driven by state rather than a counter, so + * a device that already has Chrome on but KeyGo unselected still starts at the picker and + * then goes straight to done. + */ + val nextAction: AutofillSetupAction + get() = when { + !systemAutofillEnabled -> AutofillSetupAction.OpenSystemSettings + chromeAvailable && !chromeAutofillEnabled -> AutofillSetupAction.OpenChromeSettings + else -> AutofillSetupAction.Finish + } + } +} diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml new file mode 100644 index 000000000..fa635dc76 --- /dev/null +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + + Get Started + Continue + Skip for now + Enable Biometrics + Finish setup + + + Own your credentials, own your privacy. + KeyGo encrypts everything locally. No servers, no cloud, no tracking. Just you and your data. + One more step to add your code + Complete your account setup to have this authenticator code added. + + Set your main password + This password will encrypt your vaults. KeyGo never stores this password. Make it secure. + If you forget your password, no one can recover your vaults. It will be cryptographically impossible. + Main password + Confirm main password + + Bring your passwords + Imports are encrypted locally and always on-device. Select a file to get started. + + Unlock with a touch + Use your fingerprint to open KeyGo. Your master password stays as backup. + + Set KeyGo as your autofill provider + With this feature KeyGo will offer you stored passwords whenever you are in a login screen. You can always change this setting later in your device settings. + Open system settings + Open your device settings + Choose KeyGo + Select KeyGo as your autofill provider & confirm + Apply to Chrome + Open Chrome settings & enable 3rd party autofill providers + Enable in Chrome + \ No newline at end of file diff --git a/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt new file mode 100644 index 000000000..aa6020efe --- /dev/null +++ b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/AutofillSetupTest.kt @@ -0,0 +1,168 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AutofillSetupTest { + + @Test + fun `next action opens system settings while KeyGo is not the autofill service`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = false, + ) + + assertEquals(AutofillSetupAction.OpenSystemSettings, state.nextAction) + } + + @Test + fun `next action opens system settings even when chrome is already on`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = true, + ) + + assertEquals(AutofillSetupAction.OpenSystemSettings, state.nextAction) + } + + @Test + fun `next action opens chrome settings once the system service is set`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = false, + ) + + assertEquals(AutofillSetupAction.OpenChromeSettings, state.nextAction) + } + + @Test + fun `next action finishes when both are enabled`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = true, + ) + + assertEquals(AutofillSetupAction.Finish, state.nextAction) + } + + @Test + fun `next action finishes when the system service is set and chrome is unavailable`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = false, + chromeAutofillEnabled = false, + ) + + assertEquals(AutofillSetupAction.Finish, state.nextAction) + } + + @Test + fun `setup steps start with the first row current and the rest upcoming`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = false, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Current, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Upcoming, + AutofillSetupStep.EnableInChrome to AutofillSetupStatus.Upcoming, + ), + state.setupSteps(), + ) + } + + @Test + fun `setup steps mark both system rows done together and chrome current`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = false, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Done, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Done, + AutofillSetupStep.EnableInChrome to AutofillSetupStatus.Current, + ), + state.setupSteps(), + ) + } + + @Test + fun `setup steps omit the chrome row when chrome is unavailable`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = false, + chromeAvailable = false, + chromeAutofillEnabled = false, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Current, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Upcoming, + ), + state.setupSteps(), + ) + } + + @Test + fun `setup steps mark every row done once both are enabled`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = true, + chromeAutofillEnabled = true, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Done, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Done, + AutofillSetupStep.EnableInChrome to AutofillSetupStatus.Done, + ), + state.setupSteps(), + ) + } + + @Test + fun `setup steps show chrome already done while the system rows are still pending`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = false, + chromeAvailable = true, + chromeAutofillEnabled = true, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Current, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Upcoming, + AutofillSetupStep.EnableInChrome to AutofillSetupStatus.Done, + ), + state.setupSteps(), + ) + } + + @Test + fun `setup steps have no current row when only the chrome row is missing`() { + val state = OnboardingUiState.EnableAutofill( + systemAutofillEnabled = true, + chromeAvailable = false, + chromeAutofillEnabled = false, + ) + + assertEquals( + listOf( + AutofillSetupStep.OpenSystemSettings to AutofillSetupStatus.Done, + AutofillSetupStep.ChooseKeyGo to AutofillSetupStatus.Done, + ), + state.setupSteps(), + ) + } +} diff --git a/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepTest.kt b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepTest.kt new file mode 100644 index 000000000..d44e411fb --- /dev/null +++ b/feature/onboarding/src/test/kotlin/de/davis/keygo/feature/onboarding/presentation/model/OnboardingStepTest.kt @@ -0,0 +1,91 @@ +package de.davis.keygo.feature.onboarding.presentation.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class OnboardingStepTest { + + @Test + fun `finishing the import moves on to autofill setup`() { + assertEquals( + OnboardingStep.EnableAutofillService, + OnboardingStep.ImportExistingData.nextStep(skip = emptySet()), + ) + } + + @Test + fun `finishing the import ends onboarding when autofill is already set up`() { + assertNull( + OnboardingStep.ImportExistingData.nextStep( + skip = setOf(OnboardingStep.EnableAutofillService), + ), + ) + } + + @Test + fun `a migrating user never reaches the import step`() { + assertEquals( + OnboardingStep.EnableAutofillService, + OnboardingStep.EnableBiometrics.nextStep( + skip = setOf(OnboardingStep.ImportExistingData), + ), + ) + } + + @Test + fun `every step but welcome and import allows going back`() { + assertEquals( + setOf( + OnboardingStep.SetMainPassword, + OnboardingStep.EnableBiometrics, + OnboardingStep.EnableAutofillService, + ), + OnboardingStep.entries.filter { it.canGoBack }.toSet(), + ) + } + + @Test + fun `stepping back from biometrics returns to the password step`() { + assertEquals( + OnboardingStep.SetMainPassword, + OnboardingStep.EnableBiometrics.previousStep(skip = emptySet()), + ) + } + + @Test + fun `stepping back from the password step returns to welcome`() { + assertEquals( + OnboardingStep.Welcome, + OnboardingStep.SetMainPassword.previousStep(skip = emptySet()), + ) + } + + @Test + fun `welcome has nothing to go back to`() { + assertNull(OnboardingStep.Welcome.previousStep(skip = emptySet())) + } + + @Test + fun `import is a dead end going backwards`() { + assertNull(OnboardingStep.ImportExistingData.previousStep(skip = emptySet())) + } + + @Test + fun `stepping back from autofill returns to the import step`() { + assertEquals( + OnboardingStep.ImportExistingData, + OnboardingStep.EnableAutofillService.previousStep(skip = emptySet()), + ) + } + + @Test + fun `stepping back from autofill still lands on import when biometrics was skipped`() { + assertEquals( + OnboardingStep.ImportExistingData, + OnboardingStep.EnableAutofillService.previousStep( + skip = setOf(OnboardingStep.EnableBiometrics), + ), + ) + } +} diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 9780d1ef5..42f128d0e 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -8,7 +8,6 @@ import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceReposito import de.davis.keygo.feature.autofill.domain.repository.ChromeAutofillRepository import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -70,9 +69,7 @@ internal class SettingsViewModel( // Re-read on resume: the autofill selection changes in the system picker/settings, which // run in a separate activity, so this is where we learn KeyGo was enabled or disabled. autofillEnabled.update { autofillServiceRepository.isEnabled() } - // Chrome's read is a cross-process ContentProvider query (binder IPC, can cold-start - // Chrome's process) — unlike the two reads above, keep it off the main thread. - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { chromeAutofillEnabled.update { chromeAutofillRepository.isAutofillEnabled() } } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt index 2d52c41af..6cd146b81 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordScreen.kt @@ -50,6 +50,8 @@ import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.security.domain.model.KeyId import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.core.ui.model.UiFieldError +import de.davis.keygo.core.ui.model.error import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString @@ -73,6 +75,7 @@ internal fun ChangePasswordScreen(onUp: () -> Unit) { ChangePasswordEvent.GenericError -> snackbarManager.sendMessage( SnackbarMessage(message = ResourceString(R.string.change_password_failed)), ) + ChangePasswordEvent.LaunchBiometricPrompt -> { val ciphertext = state.biometricCiphertext ?: return@ObserveAsEvents scope.launch { @@ -156,8 +159,10 @@ internal fun ChangePasswordContent( forceCompact = !it.isFocused }, label = { Text(stringResource(R.string.new_password)) }, - isError = state.newPasswordError !is FieldError.None, - supportingText = supportingTextFor(state.newPasswordError), + isError = state.newPasswordError != null, + supportingText = state.newPasswordError?.let { + { Text(text = it.error) } + }, textObfuscationMode = obfuscation(newHidden), trailingIcon = { VisibilityButton( @@ -178,8 +183,10 @@ internal fun ChangePasswordContent( state = state.confirmPassword, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.confirm_password)) }, - isError = state.confirmPasswordError !is FieldError.None, - supportingText = supportingTextFor(state.confirmPasswordError), + isError = state.confirmPasswordError != null, + supportingText = state.confirmPasswordError?.let { + { Text(text = it.error) } + }, textObfuscationMode = obfuscation(confirmHidden), trailingIcon = { VisibilityButton( @@ -243,26 +250,10 @@ internal fun ChangePasswordContent( private fun obfuscation(hidden: Boolean): TextObfuscationMode = if (hidden) TextObfuscationMode.RevealLastTyped else TextObfuscationMode.Visible -@Composable -private fun supportingTextFor(error: FieldError): (@Composable () -> Unit)? = when (error) { - FieldError.None -> null - FieldError.Empty -> { - { Text(stringResource(R.string.password_blank)) } - } - - FieldError.Incorrect -> { - { Text(stringResource(R.string.incorrect_password)) } - } - - FieldError.Mismatch -> { - { Text(stringResource(R.string.passwords_do_not_match)) } - } -} - @Composable private fun CurrentPasswordField( state: TextFieldState, - error: FieldError, + error: UiFieldError?, modifier: Modifier = Modifier, ) { var hidden by rememberSaveable { mutableStateOf(true) } @@ -270,8 +261,10 @@ private fun CurrentPasswordField( state = state, modifier = modifier.fillMaxWidth(), label = { Text(stringResource(R.string.current_password)) }, - isError = error !is FieldError.None, - supportingText = supportingTextFor(error), + isError = error != null, + supportingText = error?.let { + { Text(text = it.error) } + }, textObfuscationMode = obfuscation(hidden), trailingIcon = { VisibilityButton(isHidden = hidden, onClick = { hidden = !hidden }) @@ -288,9 +281,9 @@ private class ChangePasswordStateProvider : PreviewParameterProvider - _state.update { it.copy(currentPasswordError = FieldError.Incorrect) } + _state.update { it.copy(currentPasswordError = UiFieldError.Incorrect) } else -> _event.trySend(ChangePasswordEvent.GenericError) } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index c0201c5a7..988a3d157 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -28,9 +28,6 @@ Change password You\'ll confirm with your fingerprint Enter your current password - Password must not be blank - Incorrect password - Passwords do not match Password changed Could not change password diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt index 7b8d78232..0d57050f4 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/changepassword/ChangePasswordViewModelTest.kt @@ -9,6 +9,7 @@ import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository import de.davis.keygo.core.security.domain.model.BiometricAuthError +import de.davis.keygo.core.ui.model.UiFieldError import de.davis.keygo.core.util.Result import de.davis.keygo.rust.FakeKeyDeriver import de.davis.keygo.rust.FakeKeyWrapper @@ -93,7 +94,7 @@ class ChangePasswordViewModelTest { vm.submitWithPassword() advanceUntilIdle() - assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + assertEquals(UiFieldError.Empty, vm.state.value.newPasswordError) } @Test @@ -106,7 +107,7 @@ class ChangePasswordViewModelTest { vm.submitWithPassword() advanceUntilIdle() - assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + assertEquals(UiFieldError.Mismatch, vm.state.value.confirmPasswordError) } @Test @@ -120,8 +121,8 @@ class ChangePasswordViewModelTest { // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, // which the test scheduler cannot see. - vm.state.first { it.currentPasswordError != FieldError.None } - assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + vm.state.first { it.currentPasswordError != null } + assertEquals(UiFieldError.Incorrect, vm.state.value.currentPasswordError) } @Test @@ -162,12 +163,13 @@ class ChangePasswordViewModelTest { vm.onSubmit() advanceUntilIdle() - assertEquals(FieldError.Empty, vm.state.value.newPasswordError) + assertEquals(UiFieldError.Empty, vm.state.value.newPasswordError) } @Test fun `onSubmit without biometric and valid passwords emits Success`() = runTest(dispatcher) { - val vm = viewModel() // setUp seeds an account with no biometric ARK; availability defaults false + val vm = + viewModel() // setUp seeds an account with no biometric ARK; availability defaults false vm.state.value.currentPassword.edit { append("old") } vm.state.value.newPassword.edit { append("brand-new") } vm.state.value.confirmPassword.edit { append("brand-new") } @@ -190,28 +192,29 @@ class ChangePasswordViewModelTest { vm.onSubmit() advanceUntilIdle() - assertEquals(FieldError.Mismatch, vm.state.value.confirmPasswordError) + assertEquals(UiFieldError.Mismatch, vm.state.value.confirmPasswordError) } @Test - fun `dismissReauthDialog hides dialog and clears current password error`() = runTest(dispatcher) { - enableBiometric() - val vm = viewModel() - advanceUntilIdle() - vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog - vm.state.value.newPassword.edit { append("brand-new") } - vm.state.value.confirmPassword.edit { append("brand-new") } - vm.state.value.currentPassword.edit { append("wrong") } - vm.submitWithPassword() - // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, - // which the test scheduler cannot see. The Incorrect error below is load-bearing. - vm.state.first { it.currentPasswordError == FieldError.Incorrect } + fun `dismissReauthDialog hides dialog and clears current password error`() = + runTest(dispatcher) { + enableBiometric() + val vm = viewModel() + advanceUntilIdle() + vm.onBiometricResult(Result.Failure(BiometricAuthError.Declined)) // opens the dialog + vm.state.value.newPassword.edit { append("brand-new") } + vm.state.value.confirmPassword.edit { append("brand-new") } + vm.state.value.currentPassword.edit { append("wrong") } + vm.submitWithPassword() + // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, + // which the test scheduler cannot see. The Incorrect error below is load-bearing. + vm.state.first { it.currentPasswordError == UiFieldError.Incorrect } - vm.dismissReauthDialog() + vm.dismissReauthDialog() - assertEquals(false, vm.state.value.showReauthDialog) - assertEquals(FieldError.None, vm.state.value.currentPasswordError) - } + assertEquals(false, vm.state.value.showReauthDialog) + assertEquals(null, vm.state.value.currentPasswordError) + } @Test fun `dialog confirm with wrong current password keeps dialog open with Incorrect error`() = @@ -228,8 +231,8 @@ class ChangePasswordViewModelTest { // Await rather than advanceUntilIdle: key derivation hops to Dispatchers.Default, // which the test scheduler cannot see. - vm.state.first { it.currentPasswordError != FieldError.None } - assertEquals(FieldError.Incorrect, vm.state.value.currentPasswordError) + vm.state.first { it.currentPasswordError != null } + assertEquals(UiFieldError.Incorrect, vm.state.value.currentPasswordError) assertEquals(true, vm.state.value.showReauthDialog) } diff --git a/gradle.properties b/gradle.properties index 132244e5b..4c51ec73c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx3g -XX:+UseParallelGC -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -20,4 +20,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9115ff64..ddee4dd9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ robolectric = "4.16.1" emvnfccard = "3.1.0" aboutlibraries = "14.2.1" work = "2.11.2" +splashscreen = "1.2.0" [libraries] google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } @@ -52,6 +53,7 @@ kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationJson" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collectionsImmutable" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -71,6 +73,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3", androidx-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } androidx-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigation" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 29d162c2d..c7e735f27 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,3 +50,4 @@ include(":feature:autofill") include(":feature:credit-card") include(":feature:settings") include(":feature:backup") +include(":feature:onboarding")