Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6b579a3
feat(onboarding): add module
OffRange Aug 3, 2026
4fc1e57
feat(onboarding): add prototypes
OffRange Aug 4, 2026
f102408
refactor(core): move UI field error state
OffRange Aug 5, 2026
ddc7e85
feat(onboarding): wire vm
OffRange Aug 5, 2026
0a46da0
feat(onboarding): skip steps if not needed
OffRange Aug 5, 2026
cff16f5
feat(onboarding): wire up access creation
OffRange Aug 5, 2026
75c43c6
feat(onboarding): show different message for migration
OffRange Aug 6, 2026
c6f8fe6
feat(onboarding): wire up autofill setup
OffRange Aug 7, 2026
bbeed27
fix(onboarding): coroutine leak
OffRange Aug 7, 2026
1bcca09
feat(onboarding): wire up import data
OffRange Aug 8, 2026
862f193
refactor(auth): drop migration and create access path
OffRange Aug 9, 2026
0e6a402
fix(onboarding): make resposive
OffRange Aug 9, 2026
bd59881
Revert "refactor(auth): drop migration and create access path"
OffRange Aug 10, 2026
e4dfcd7
refactor: drop migration from onboarding and create access from auth
OffRange Aug 10, 2026
0da69e8
chore: ignore .worktrees/ directory
OffRange Aug 10, 2026
1a214d7
refactor(totp): outsource deep link
OffRange Aug 10, 2026
c876dd9
refactor(onboarding): cleanup
OffRange Aug 10, 2026
ec640e7
fix: navigate user to auth if migration is possible
OffRange Aug 10, 2026
f379b22
fix: auto biometric prompt
OffRange Aug 11, 2026
646bcc7
feat(onboarding): add indicator
OffRange Aug 11, 2026
c17ae20
fix: review findings
OffRange Aug 11, 2026
2ca73ed
Merge branch 'v2' into feat/onboarding
OffRange Aug 11, 2026
b5d84b3
ci: add concurrency
OffRange Aug 11, 2026
1ad58c5
fix: import
OffRange Aug 11, 2026
b477256
chore: increase heap size
OffRange Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ google-services.json
docs/superpowers/
.superpowers/
.claude/
.worktrees/

graphify-out/
.gitattributes
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/de/davis/keygo/app/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,6 +10,7 @@ import org.koin.core.annotation.Module
DashboardModule::class,
]
)
@ComponentScan("de.davis.keygo.app")
@Configuration
object AppModule

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean?>(null)
val isReturningUser = _isReturningUser.asStateFlow()

init {
viewModelScope.launch {
_isReturningUser.update { accountRepository.getOrNull() != null || hasV1Password() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,35 +47,48 @@ 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<AppViewModel>()

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<SnackbarManager>()
CompositionLocalProvider(
LocalSnackbarManager provides snackbarManager,
) {
App()
App(hasAccess = hasAccess == true)
}
}
}
}
}

@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<DetailType>()
val navController = rememberNavController()

Expand All @@ -98,7 +111,6 @@ private fun App() {
SnackbarHandler(snackbarHostState)

val scope = rememberCoroutineScope()

KeyGoNavigationWrapper(
currentDestination = currentDestination,
navigateToTopLevelDestination = {
Expand Down Expand Up @@ -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<TotpImportRedirect> { inclusive = true }
}
}
)

authGraph(
onSuccess = { totpUri ->
val dest = totpUri?.let {
Expand All @@ -144,6 +165,18 @@ private fun App() {
}
)

onboardingGraph(
onSuccess = { totpUri ->
val dest = totpUri?.let {
RouteDestination.Home.Root(it)
} ?: RouteDestination.TopLevelAppGraph

navController.navigate(dest) {
popUpTo<OnboardingRoute> { inclusive = true }
}
}
)

navigation<RouteDestination.TopLevelAppGraph>(
startDestination = RouteDestination.Home.NavGraph
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TotpImportRedirect>(
deepLinks = listOf(
navDeepLink<TotpImportRedirect>(basePath = PendingTotpImport.BASE_PATH) {
uriPattern = PendingTotpImport.URI_PATTERN
}
)
) { entry ->
val route = entry.toRoute<TotpImportRedirect>()
LaunchedEffect(route) {
navigateAndReplace(
if (hasAccess) AuthRoute(totpInfo = route.totpInfo, queries = route.queries)
else OnboardingRoute(totpInfo = route.totpInfo, queries = route.queries)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AuthRoute>() == true)
}

@Test
fun `graph builds for an account without access`() {
val controller = navController(hasAccess = false)

assertTrue(controller.currentDestination?.hasRoute<OnboardingRoute>() == 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<TotpImportRedirect>())

val route = entry.toRoute<TotpImportRedirect>()
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<AuthRoute>()
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<OnboardingRoute>()
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<AuthRoute>()
assertEquals(PendingTotpImport(), route.pendingTotpImport)
assertNull(route.uri)
}
}
1 change: 1 addition & 0 deletions core/ui/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.keygo.android.compose)
alias(libs.plugins.kotlin.serialization)
}

android {
Expand Down
Original file line number Diff line number Diff line change
@@ -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}"
}
}
Loading
Loading