diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 711f499379..765b03342a 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -37,6 +37,7 @@ import com.flipcash.app.messenger.ChatFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen +import com.flipcash.app.login.AccountSelectionScreen import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.BlocklistScreen @@ -148,6 +149,7 @@ fun appEntryProvider( annotatedEntry { BackupKeyScreen() } annotatedEntry { AdvancedFeaturesScreen() } annotatedEntry { DeviceLogsScreen() } + annotatedEntry { AccountSelectionScreen() } annotatedEntry { UserFlagsScreen() } diff --git a/apps/flipcash/app/src/main/res/xml/backup_rules.xml b/apps/flipcash/app/src/main/res/xml/backup_rules.xml index 517e6b9f92..cb113673a4 100644 --- a/apps/flipcash/app/src/main/res/xml/backup_rules.xml +++ b/apps/flipcash/app/src/main/res/xml/backup_rules.xml @@ -9,4 +9,7 @@ + diff --git a/apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml b/apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml index 479f3be749..c27e357f2c 100644 --- a/apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml +++ b/apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml @@ -10,6 +10,9 @@ + + diff --git a/apps/flipcash/core/build.gradle.kts b/apps/flipcash/core/build.gradle.kts index 19f4aca024..71896ec05e 100644 --- a/apps/flipcash/core/build.gradle.kts +++ b/apps/flipcash/core/build.gradle.kts @@ -21,8 +21,6 @@ dependencies { implementation(libs.firebase.messaging) implementation(libs.androidx.localbroadcastmanager) - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.play.auth) implementation(libs.androidx.datastore) implementation(libs.compose.material3) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index cabf6d3534..a3fec04947 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -309,6 +309,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object DeviceLogs : Menu @Serializable + data object AccountSelection : Menu + @Serializable data object UserProfile : Menu @Serializable data class Lab(val onboarding: Boolean = false) : Menu diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt index 730c3d50c9..318fcaf3f0 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt @@ -20,6 +20,10 @@ sealed interface OnboardingStep : FlowStep, Parcelable { @Serializable data object SeedInput : OnboardingStep + @Parcelize + @Serializable + data object AccountSelection : OnboardingStep + @Parcelize @Serializable data object AccessKey : OnboardingStep diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 2a875bd837..30901bc938 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -78,9 +78,6 @@ Valid address - Something went wrong - Failed to restore selected account. Please try again - Your camera is used to grab Digital Cash. Please allow access to the camera to proceed Push Notifications Required Push notifications are used to update you on tips and messages diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt index 5a8584e0b7..3c03919518 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt @@ -65,13 +65,6 @@ fun AdvancedFeaturesScreen() { .launchIn(this) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.hide() } - .launchIn(this) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt index be6303aaad..a03a2151c4 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute -import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.core.R @@ -56,6 +55,20 @@ internal data object BetaFlags : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) + override val name: String + @Composable get() = stringResource(R.string.title_switchAccounts) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.AccountSelection) +} + internal data object LogOut : FullMenuItem() { override val icon: Painter @Composable get() = painterResource(R.drawable.ic_menu_logout) @@ -73,18 +86,3 @@ internal data object DeleteAccount : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) - override val name: String - @Composable get() = stringResource(R.string.title_switchAccounts) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OnSwitchAccountsClicked - override val featureFlag: FeatureFlag<*> = FeatureFlag.CredentialManager -} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt index 0be3e9d3b8..9eaca28ea8 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt @@ -3,13 +3,11 @@ package com.flipcash.app.advanced.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.app.userflags.UserFlagsCoordinator -import com.getcode.opencode.managers.MnemonicManager import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction @@ -26,7 +24,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -private val FullMenuList = buildList { +private val FullMenuList: List> = buildList { add(AccessKey) add(BetaFlags) add(DeviceLogs) @@ -42,7 +40,6 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( userFlags: UserFlagsCoordinator, resources: ResourceHelper, authManager: AuthManager, - mnemonicManager: MnemonicManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -55,7 +52,7 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( // Default hides staff-only AND flag-gated items until the real state loads, so a beta-gated // row never flashes before its flag resolves. val items: List> = - FullMenuList.filterNot { it is StaffMenuItem || it.featureFlag != null }, + FullMenuList.filterNot { it is StaffMenuItem<*> || it.featureFlag != null }, ) sealed interface Event { @@ -64,8 +61,6 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( val flags: List = emptyList(), ) : Event data class OpenScreen(val screen: AppRoute) : Event - data object OnSwitchAccountsClicked : Event - data class OnSwitchAccountTo(val entropy: String) : Event data object OpenBillPlayground : Event data object OnAccessKeyClicked : Event data object OnViewAccessKey : Event @@ -84,24 +79,6 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( dispatchEvent(Event.OnBetaFeaturesUnlocked(override || isStaff, flags)) }.launchIn(viewModelScope) - // Hands off to Google's Password Manager to pick another access key, then re-logs in as it. - eventFlow - .filterIsInstance() - .map { - authManager.selectAccount() - .fold( - onSuccess = { - authManager.logoutAndSwitchAccount( - mnemonicManager.getEncodedBase64(it) - ) - }, - onFailure = { Result.failure(it) } - ) - }.onResult( - onError = { }, - onSuccess = { dispatchEvent(Event.OnSwitchAccountTo(it)) } - ).launchIn(viewModelScope) - eventFlow .filterIsInstance() .onEach { @@ -179,7 +156,7 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( unlocked: Boolean, flags: List, ): List> = FullMenuList - .filter { it !is StaffMenuItem || unlocked } + .filter { it !is StaffMenuItem<*> || unlocked } .filter { item -> val flag = item.featureFlag ?: return@filter true flags.find { it.flag.key == flag.key }?.enabled == true @@ -196,8 +173,6 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( } is Event.OpenScreen, - Event.OnSwitchAccountsClicked, - is Event.OnSwitchAccountTo, Event.OpenBillPlayground, Event.OnAccessKeyClicked, Event.OnViewAccessKey, diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index 6f605f4fb6..5316fb5af4 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -24,4 +24,5 @@ dependencies { implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) + implementation(project(":services:opencode")) } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/AccountSelectionScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/AccountSelectionScreen.kt new file mode 100644 index 0000000000..047686d3d2 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/AccountSelectionScreen.kt @@ -0,0 +1,56 @@ +package com.flipcash.app.login + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.login.internal.accounts.AccountSelectionContent +import com.flipcash.app.login.internal.accounts.AccountSelectionViewModel +import com.flipcash.features.login.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarWithTitle + +/** + * The logged-in switcher. Selecting a row logs out and parks the entropy; `App.kt` picks it up + * once the logout lands and restarts onboarding with it. + * + * The "Enter a Different Access Key" footer is hidden here: from inside the app there is no + * onboarding flow to fall through to, and a logged-in user reaching for a new access key is + * logging out, which the menu already offers. That makes the back control the only way out, so + * the bar is not optional — a Block Store read that returns nothing would otherwise leave a + * signed-in user on a screen with nothing to press. + */ +@Composable +fun AccountSelectionScreen() { + val navigator = LocalCodeNavigator.current + val viewModel: AccountSelectionViewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.title_selectAccount), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + AccountSelectionContent( + state = state, + onSelect = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountSelected(entropy)) + }, + onRemove = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnRemoveRequested(entropy)) + }, + showEnterAccessKey = false, + ) + } +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 43d95ec2f0..94bafbdeab 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -35,6 +35,8 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.core.onboarding.OnboardingResult import com.flipcash.app.core.onboarding.OnboardingStep import com.flipcash.app.login.internal.LoginAccessKeyViewModel +import com.flipcash.app.login.internal.accounts.AccountSelectionContent +import com.flipcash.app.login.internal.accounts.AccountSelectionViewModel import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen import com.flipcash.app.login.internal.screens.AccessKeyScreen import com.flipcash.app.login.internal.screens.LoginRouterScreenContent @@ -61,6 +63,7 @@ import com.getcode.navigation.core.NavOptions import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.FlowNavigator +import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultStateRegistry @@ -86,10 +89,13 @@ import kotlin.time.Duration.Companion.milliseconds * Start → AccessKey ──┬────────────→ Name² → Contacts¹ → Notifications → Home³ * └→ Purchase ─┘ * - * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) + * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput or AccountSelection) * - * Start → SeedInput ──┬────────────→ Name² → Contacts¹ → Notifications → Home³ - * └→ Purchase ─┘ + * Start ─┬─ AccountSelection ─┬──┬────────────→ Name² → Contacts¹ → Notifications → Home³ + * │ (stored accounts)│ └→ Purchase ─┘ + * └─ SeedInput ────────┘ + * Either entry reaches Purchase when the restored account still owes one. + * AccountSelection also falls through to SeedInput via "Enter a Different Access Key". * * 3. App resume (ResumePoint.PostAccessKey) * @@ -293,6 +299,9 @@ private fun onboardingEntryProvider( annotatedEntry { SeedInputStepContent() } + annotatedEntry(testTag = "account_selection_screen") { + AccountSelectionStepContent() + } annotatedEntry { AccessKeyStepContent() } @@ -315,30 +324,17 @@ private fun onboardingEntryProvider( // -- Step composables -- +/** + * What happens once a login succeeds, wired identically wherever one can be started. Both the + * landing step (a deeplink seed) and the account list (a row tap) dispatch `LogIn` on the same + * flow-scoped [LoginViewModel], so both need the same two outcomes: straight out of onboarding, or + * into the purchase leg when the account has not paid for registration yet. + */ @Composable -private fun LoginStepContent(seed: String?) { - val vm = hiltViewModel() - val state by vm.stateFlow.collectAsStateWithLifecycle() - val flowNavigator = rememberFlowNavigator() - val navigator = LocalCodeNavigator.current - var visible by remember { mutableStateOf(false) } - val activity = LocalActivity.current - - LaunchedEffect(Unit) { - activity?.reportFullyDrawn() - visible = true - } - - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { - trace(tag = "Onboarding", message = "Account created — navigating to access key", type = TraceType.Process) - flowNavigator.navigateTo(OnboardingStep.AccessKey) - } - .launchIn(this) - } - +private fun LoginOutcomeHandler( + vm: LoginViewModel, + flowNavigator: FlowNavigator, +) { LaunchedEffect(vm) { vm.eventFlow .filterIsInstance() @@ -362,6 +358,53 @@ private fun LoginStepContent(seed: String?) { } .launchIn(this) } +} + +@Composable +private fun LoginStepContent(seed: String?) { + // Shared with AccountSelectionStepContent: both steps start a login on it, and a second + // instance would repeat the stored-account read and split the login state between them. + val vm = flowSharedViewModel() + val state by vm.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current + var visible by remember { mutableStateOf(false) } + var loginRequested by remember { mutableStateOf(false) } + val activity = LocalActivity.current + + LaunchedEffect(Unit) { + activity?.reportFullyDrawn() + visible = true + } + + // The stored-account read is asynchronous, so a tap on a cold start can arrive before it + // lands. Hold the tap and route once the answer is in, rather than routing on the `false` + // default and sending someone who does have accounts to the access key field. + LaunchedEffect(loginRequested, state.storedAccountsChecked) { + if (!loginRequested || !state.storedAccountsChecked) return@LaunchedEffect + loginRequested = false + // Mirrors iOS's OnboardingViewModel.loginAction: the list when we have one, the access + // key field when we do not. + flowNavigator.navigateTo( + if (state.hasStoredAccounts) { + OnboardingStep.AccountSelection + } else { + OnboardingStep.SeedInput + } + ) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { + trace(tag = "Onboarding", message = "Account created — navigating to access key", type = TraceType.Process) + flowNavigator.navigateTo(OnboardingStep.AccessKey) + } + .launchIn(this) + } + + LoginOutcomeHandler(vm, flowNavigator) LaunchedEffect(seed) { if (seed != null) { @@ -374,10 +417,16 @@ private fun LoginStepContent(seed: String?) { enter = fadeIn(tween(150)), ) { LoginRouterScreenContent( - isLoggingIn = state.loggingIn, + // A held tap shows the button's spinner, so waiting on the read reads as the app + // working rather than as a dead button. + isLoggingIn = if (loginRequested) { + state.loggingIn.copy(loading = true) + } else { + state.loggingIn + }, isCreatingAccount = state.creatingAccount, createAccount = { vm.dispatchEvent(LoginViewModel.Event.CreateAccount) }, - login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, + login = { loginRequested = true }, isLabsOpen = state.betaOptionsVisible, onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, openBetaFlags = { navigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) }, @@ -419,6 +468,42 @@ private fun SeedInputStepContent() { } } +@Composable +private fun AccountSelectionStepContent() { + val viewModel: AccountSelectionViewModel = hiltViewModel() + val loginViewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LoginOutcomeHandler(loginViewModel, flowNavigator) + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // The back control is what keeps this step from being a dead end, as SeedInput's sibling + // bar shows. + AppBarWithTitle( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.title_selectAccount), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + AccountSelectionContent( + state = state, + // The store holds base64 entropy, and LogIn defaults to fromDeeplink = false, which + // is the base64 path. Do not pass fromDeeplink = true -- that branch base58-decodes. + onSelect = { entropy -> + loginViewModel.dispatchEvent(LoginViewModel.Event.LogIn(entropy)) + }, + onRemove = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnRemoveRequested(entropy)) + }, + onEnterAccessKey = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, + ) + } +} + @Composable private fun AccessKeyStepContent() { val viewModel = hiltViewModel() diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt index 9a905b8690..237bd70597 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt @@ -2,7 +2,6 @@ package com.flipcash.app.login.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager -import com.flipcash.app.auth.internal.credentials.SelectCredentialError import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.login.R @@ -142,26 +141,6 @@ internal class SeedInputViewModel @Inject constructor( } } - suspend fun restoreAccount(): Result { - return authManager.selectAccount() - .onSuccess { mnemonic -> - performLogin( - entropyB64 = mnemonic.getBase64EncodedEntropy(), - isRestore = true, - ) - }.onFailure { error -> - when (error) { - is SelectCredentialError.UserCancelled -> { /* no op */ } - else -> { - BottomBarManager.showError( - resources.getString(R.string.error_title_selectCredential), - resources.getString(R.string.error_description_selectCredential) - ) - } - } - }.map { Unit } - } - private fun setState(isLoading: Boolean, isSuccess: Boolean, isContinueEnabled: Boolean) { uiFlow.update { it.copy( diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt new file mode 100644 index 0000000000..9ce34d89de --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt @@ -0,0 +1,106 @@ +package com.flipcash.app.login.internal.accounts + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.features.login.R +import com.getcode.theme.CodeTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun AccountRow( + account: AccountSelectionViewModel.AccountUiModel, + isCurrent: Boolean, + relativeCreationDate: String, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + enabled = !isCurrent, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // The tick is laid out for every row so the names line up; it is only drawn, and only + // announced, for the account the user is signed into. + Icon( + modifier = Modifier.size(CodeTheme.dimens.grid.x4), + imageVector = Icons.Default.Check, + contentDescription = if (isCurrent) { + stringResource(R.string.subtitle_currentAccount) + } else { + null + }, + tint = if (isCurrent) CodeTheme.colors.textMain else Color.Transparent, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = account.name, + // textLarge is the primary line on the app's other list rows (Blocklist). + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.subtitle_accountCreated, relativeCreationDate), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = account.ownerAddress, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + // Order matters: a resolved balance wins, then the backend's own "no such account", then + // our inability to ask. An account whose balance we could not fetch must not be reported as + // not found — the two say very different things to someone checking their own wallet. + when { + account.balance != null -> Text( + text = account.balance.formatted(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + + account.notFound -> Text( + text = stringResource(R.string.subtitle_accountNotFound), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + account.balanceUnavailable -> Text( + text = stringResource(R.string.subtitle_balanceUnavailable), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt new file mode 100644 index 0000000000..d5c6bbb857 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.login.internal.accounts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.features.login.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator + +@Composable +internal fun AccountSelectionContent( + state: AccountSelectionViewModel.State, + onSelect: (String) -> Unit, + onRemove: (String) -> Unit, + modifier: Modifier = Modifier, + /** + * The in-app switcher has no onboarding flow to fall through to, so it hides the footer and + * leaves [onEnterAccessKey] alone. + */ + showEnterAccessKey: Boolean = true, + onEnterAccessKey: () -> Unit = {}, +) { + // The title lives in each caller's AppBarWithTitle, as on every other list screen here + // (Blocklist, Advanced). A second heading drawn inside the content sat below the bar and made + // this the one screen with two title rows. + Column(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + when { + // Read the list before saying it is empty, or every cold start flashes the + // empty state. + state.loading -> CodeCircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.grid.x6), + ) + + // A Block Store read that comes back with nothing is the case that strands the + // in-app switcher, which has no "Enter a Different Access Key" footer to leave by. + state.accounts.isEmpty() -> Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.subtitle_noStoredAccounts), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset), + ) { + items(state.accounts, key = { it.id }) { account -> + AccountRow( + account = account, + isCurrent = account.entropy == state.currentEntropy, + relativeCreationDate = remember(account.creationDate) { + formatRelative(account.creationDate) + }, + onClick = { onSelect(account.entropy) }, + onLongClick = { onRemove(account.entropy) }, + ) + } + } + } + } + + if (showEnterAccessKey) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x4), + onClick = onEnterAccessKey, + text = stringResource(R.string.action_enterDifferentAccessKey), + buttonState = ButtonState.Subtle, + ) + } + } +} + +private fun formatRelative(epochMillis: Long): String = + android.text.format.DateUtils.getRelativeTimeSpanString(epochMillis).toString() diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt new file mode 100644 index 0000000000..966f2c71a9 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt @@ -0,0 +1,351 @@ +package com.flipcash.app.login.internal.accounts + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.auth.internal.accounts.AccountRecord +import com.flipcash.features.login.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TokenController +import com.getcode.opencode.managers.MnemonicManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenResult +import com.getcode.opencode.model.financial.sum +import com.getcode.opencode.model.financial.usdf +import com.getcode.opencode.providers.TokenMetadataProvider +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class AccountSelectionViewModel @Inject constructor( + private val authManager: AuthManager, + private val mnemonicManager: MnemonicManager, + private val tokenController: TokenController, + private val resources: ResourceHelper, + private val dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + + /** + * TokenController does not implement [TokenMetadataProvider] itself (only TokenCoordinator + * does, scoped to the single signed-in user, which is wrong for this screen's other, dormant + * accounts), so this screen brings its own, delegating to TokenController's network-tier + * lookup with no cache — there is no warm cache here to benefit from anyway. + * + * One instance per account fetch, because it also records whether any lookup failed. + * `fetchTokenAccounts` resolves metadata through `mapNotNull`, so a failed lookup silently + * drops that token: without this flag an account whose metadata we could not read is + * indistinguishable from one the backend has never heard of, and the row would say + * "Not Found" about a wallet that exists. + */ + private inner class RecordingMetadataProvider : TokenMetadataProvider { + var failed: Boolean = false + private set + + override suspend fun getTokenMetadata(mint: Mint): Result = + tokenController.getTokenMetadata(mint).onFailure { failed = true } + + override fun observeTokenCache(): Flow> = emptyFlow() + } + + /** + * One row. All three result fields false/null is the in-flight state. + * + * [notFound] is the terminal state for an account the backend confirms it does not know, + * matching iOS's "Not Found" badge. [balanceUnavailable] is the separate case where the fetch + * itself failed — offline, or the backend is down. Collapsing the two would tell a user with no + * connection that every wallet they own is gone, which on this screen is the worst thing we + * could say. + */ + data class AccountUiModel( + /** + * The list's stable key. A LazyColumn key is written into the saved-instance-state + * bundle, so it must not be the seed. The owner key is public. + */ + val id: String, + val entropy: String, + val name: String, + val ownerAddress: String, + val creationDate: Long, + val balance: Fiat? = null, + val notFound: Boolean = false, + val balanceUnavailable: Boolean = false, + ) { + // The default data class toString() would put a seed into every log line and crash report + // that touches this state. + override fun toString(): String = "AccountUiModel(owner=$ownerAddress)" + } + + data class State( + val accounts: List = emptyList(), + val loading: Boolean = true, + val currentEntropy: String? = null, + ) + + sealed interface Event { + /** Read the list and start a fresh balance pass. */ + data object Load : Event + + data class OnAccountsLoaded( + val accounts: List, + val currentEntropy: String?, + ) : Event + + data class OnBalanceResolved(val entropy: String, val balance: Fiat) : Event + data class OnBalanceNotFound(val entropy: String) : Event + data class OnBalanceUnavailable(val entropy: String) : Event + data class OnAccountSelected(val entropy: String) : Event + data class OnRemoveRequested(val entropy: String) : Event + data class OnAccountRemoved(val entropy: String) : Event + } + + private val switchInFlight = AtomicBoolean(false) + + init { + // One pipeline for the list and its balances. `flatMapLatest` is what makes a reload + // abandon the balance pass already in flight, so its late results cannot land on rows the + // new list has just reset. + eventFlow + .filterIsInstance() + // Nothing outside drives this screen, so the pipeline seeds its own first read. + .onStart { emit(Event.Load) } + .map { derive(authManager.accounts.all()) } + .onEach { derived -> + dispatchEvent( + Event.OnAccountsLoaded( + accounts = derived.map { (record, cluster) -> + record.toUiModel(cluster.getOrNull()) + }, + currentEntropy = authManager.currentEntropy, + ) + ) + } + .flatMapLatest { derived -> derived.asFlow().flatMapMerge { balance(it) } } + .onEach { dispatchEvent(it) } + // Derivation is PBKDF2 plus a SLIP-10 chain, and the fetches are network calls; + // neither belongs on the thread drawing the list. + .flowOn(dispatchers.IO) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { event -> + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_removeAccount), + message = resources.getString(R.string.prompt_description_removeAccount), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_removeAccount)) { + dispatchEvent(Event.OnAccountRemoved(event.entropy)) + } + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + // Removing the account the user is signed into would strand them in a session whose + // account is no longer listed. The row is also not clickable, so this is defence in + // depth against a stale event rather than the primary guard. + .filter { it.entropy != authManager.currentEntropy } + // A throw here would cancel the collector, and with it every later removal on this + // screen. The store is best effort; a failed removal is a reload that shows the row + // still there, not a dead screen. + .onEach { event -> + runCatching { authManager.accounts.setDeleted(event.entropy, deleted = true) } + .onFailure { error -> + trace( + tag = TAG, + message = "Could not remove a stored account", + error = error, + type = TraceType.Error, + ) + } + } + .onEach { dispatchEvent(Event.Load) } + .flowOn(dispatchers.IO) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + // Two taps land two logouts, each parking its own entropy, and the second wins the + // race for what App.kt restarts onboarding with. The guard is never reset: a switch + // tears this screen down, so there is no second legitimate selection to allow. + .filter { switchInFlight.compareAndSet(false, true) } + .onEach { event -> + val mnemonic = mnemonicManager.fromEntropyBase64(event.entropy) + // App.kt feeds this to OnboardingFlow(seed = ...), which base58-decodes it. + authManager.logoutAndSwitchAccount(mnemonicManager.getEncodedBase58(mnemonic)) + } + .flowOn(dispatchers.IO) + .launchIn(viewModelScope) + } + + /** + * Derivation is done once per account and carried, not recomputed per use: it is a PBKDF2 seed + * stretch plus a SLIP-10 chain, and this list can hold 50 accounts. A record that will not + * derive is kept rather than dropped — the user should still see it. + */ + private fun derive(records: List): List>> = + records.map { record -> record to runCatching { clusterFor(record.entropy) } } + + private fun AccountRecord.toUiModel(cluster: AccountCluster?): AccountUiModel { + val mnemonic = runCatching { mnemonicManager.fromEntropyBase64(entropy) }.getOrNull() + val owner = cluster?.authorityPublicKey?.base58() + return AccountUiModel( + // A record that will not derive has no owner key to be keyed on; its creation + // timestamp is the only other thing distinguishing it. + id = owner ?: "underived-$creationDate", + entropy = entropy, + name = mnemonic?.let { displayName(it) }.orEmpty(), + ownerAddress = owner?.let { truncateAddress(it) }.orEmpty(), + creationDate = creationDate, + // A record we cannot derive from is one we can never fetch a balance for. + balanceUnavailable = cluster == null, + ) + } + + private fun clusterFor(entropy: String): AccountCluster { + val mnemonic = mnemonicManager.fromEntropyBase64(entropy) + val authority = DerivedKey.derive(DerivePath.primary, mnemonic) + return AccountCluster.newInstance(authority = authority, token = Token.usdf) + } + + /** + * One account's balance, as a single-event flow so the rows fill in as results land rather + * than all at the end. Each account is fetched with its own owner key as both the account + * owner and the requesting owner — a self-signed request, which is what makes this work while + * logged out. + */ + private fun balance(entry: Pair>): Flow = flow { + val (record, cluster) = entry + val owner = cluster.getOrElse { error -> + trace( + tag = TAG, + message = "Could not derive an owner key for a stored account", + error = error, + type = TraceType.Error, + ) + return@flow emit(Event.OnBalanceUnavailable(record.entropy)) + } + + val metadata = RecordingMetadataProvider() + tokenController.fetchTokenAccounts(owner, metadata) + .onSuccess { tokens -> + when { + // A dropped token makes the total wrong as surely as it makes an empty list + // meaningless, so a failed lookup rules out both other answers. + metadata.failed -> emit(Event.OnBalanceUnavailable(record.entropy)) + tokens.isEmpty() -> emit(Event.OnBalanceNotFound(record.entropy)) + else -> emit( + Event.OnBalanceResolved( + entropy = record.entropy, + balance = tokens.map { it.balance }.sum(), + ) + ) + } + } + .onFailure { error -> + trace( + tag = TAG, + message = "Balance fetch failed for a stored account", + error = error, + type = TraceType.Error, + ) + emit(Event.OnBalanceUnavailable(record.entropy)) + } + } + + companion object { + private const val TAG = "AccountSelection" + + /** iOS's `MnemonicPhrase.name`: first word, ellipsis, last word, both capitalised. */ + fun displayName(mnemonic: MnemonicPhrase): String = + listOf(mnemonic.words.first(), mnemonic.words.last()) + .joinToString(" ... ") { word -> + word.lowercase().replaceFirstChar { it.titlecase() } + } + + fun truncateAddress(address: String): String = + if (address.length <= 8) address + else "${address.take(4)}...${address.takeLast(4)}" + + private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnAccountsLoaded -> { state -> + state.copy( + accounts = event.accounts, + loading = false, + currentEntropy = event.currentEntropy, + ) + } + + is Event.OnBalanceResolved -> { state -> + state.copy( + accounts = state.accounts.map { + if (it.entropy == event.entropy) it.copy(balance = event.balance) else it + } + ) + } + + is Event.OnBalanceNotFound -> { state -> + state.copy( + accounts = state.accounts.map { + if (it.entropy == event.entropy) it.copy(notFound = true) else it + } + ) + } + + is Event.OnBalanceUnavailable -> { state -> + state.copy( + accounts = state.accounts.map { + if (it.entropy == event.entropy) { + it.copy(balanceUnavailable = true) + } else { + it + } + } + ) + } + + is Event.Load, + is Event.OnAccountSelected, + is Event.OnRemoveRequested, + is Event.OnAccountRemoved -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt index 2183656edb..4f60e1dd82 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt @@ -13,13 +13,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.FloatingActionButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.SettingsBackupRestore import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -30,7 +26,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -41,8 +36,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.login.internal.SeedInputUiModel import com.flipcash.app.login.internal.SeedInputViewModel import com.flipcash.features.login.R @@ -70,7 +63,6 @@ internal fun SeedInputContent( state = dataState, onTextChange = { viewModel.onTextChange(it) }, onLogin = { viewModel.onSubmit() }, - onRestore = { viewModel.restoreAccount() }, onCantFind = onCantFind, ) } @@ -80,13 +72,10 @@ private fun SeedInputContent( state: SeedInputUiModel, onTextChange: (String) -> Unit, onLogin: () -> Unit, - onRestore: suspend () -> Unit, onCantFind: () -> Unit, ) { val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } - val featureFlags = LocalFeatureFlags.current - val restoreEnabled by featureFlags.observe(FeatureFlag.CredentialManager).collectAsStateWithLifecycle() val keyboardVisible by keyboardAsState() val ime = LocalSoftwareKeyboardController.current @@ -97,42 +86,6 @@ private fun SeedInputContent( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars), - floatingActionButton = { - if (restoreEnabled) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), - ) { - Text( - modifier = Modifier, - text = stringResource(R.string.action_recoverExistingAccount), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary - ) - - FloatingActionButton( - backgroundColor = CodeTheme.colors.brandMuted, - contentColor = CodeTheme.colors.textMain, - shape = CircleShape, - onClick = { - composeScope.launch { - if (keyboardVisible) { - ime?.hide() - delay(500.scaled(animationScale)) - } - onRestore() - } - } - ) { - Image( - imageVector = Icons.Default.SettingsBackupRestore, - contentDescription = null, - colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), - ) - } - } - } - } ) { padding -> Column( modifier = Modifier diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt index 0c511bdb82..46f1111c11 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt @@ -16,6 +16,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -45,6 +47,13 @@ class LoginViewModel @Inject constructor( val creatingAccount: LoadingSuccessState = LoadingSuccessState(), val logoTapCount: Int = 0, val betaOptionsVisible: Boolean = false, + val hasStoredAccounts: Boolean = false, + /** + * Whether the stored-account read has come back. [hasStoredAccounts] defaults to false, so + * routing on it before this is true would send someone who has accounts to the access key + * field. Callers must wait for it rather than treat the default as an answer. + */ + val storedAccountsChecked: Boolean = false, ) sealed interface Event { @@ -59,10 +68,17 @@ class LoginViewModel @Inject constructor( data object OnAccountCreated : Event data object CreateAccountSettled : Event data object CreateFailed : Event + data class OnStoredAccountsChanged(val hasAccounts: Boolean) : Event } private val createInFlight = AtomicBoolean(false) + /** + * A second tap while a login is in flight starts a second one, and the two race + * `UserManager.establish`. Reset on every outcome so a failed attempt can be retried. + */ + private val loginInFlight = AtomicBoolean(false) + init { eventFlow .filterIsInstance() @@ -138,31 +154,41 @@ class LoginViewModel @Inject constructor( eventFlow .filterIsInstance() + .filter { loginInFlight.compareAndSet(false, true) } .map { it.entropyB64 } .onEach { entropyB64 -> - authManager.login( - entropyB64 = entropyB64, - // treat deep links and account switches as if they came from the selection screen - isFromSelection = true - ).onFailure { - dispatchEvent(Event.LogInFailed) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_loginFailed), - message = it.localizedMessage ?: resources.getString(R.string.error_description_loginFailed), - ) - }.onSuccess { - accounts.getUserFlags() - .onSuccess { - if (it.isRegistered || !it.requiresIapForRegistration) { - dispatchEvent(Event.LoggedInSuccessfully) - } else { - dispatchEvent(Event.LoggedInRequiresPayment) + try { + authManager.login( + entropyB64 = entropyB64, + // treat deep links and account switches as if they came from the selection screen + isFromSelection = true + ).onFailure { + dispatchEvent(Event.LogInFailed) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_loginFailed), + message = it.localizedMessage ?: resources.getString(R.string.error_description_loginFailed), + ) + }.onSuccess { + accounts.getUserFlags() + .onSuccess { + if (it.isRegistered || !it.requiresIapForRegistration) { + dispatchEvent(Event.LoggedInSuccessfully) + } else { + dispatchEvent(Event.LoggedInRequiresPayment) + } + }.onFailure { + dispatchEvent(Event.LogInFailed) } - }.onFailure { - dispatchEvent(Event.LogInFailed) - } + } + } finally { + loginInFlight.set(false) } }.launchIn(viewModelScope) + + flow { emit(authManager.accounts.all().isNotEmpty()) } + .onEach { dispatchEvent(Event.OnStoredAccountsChanged(it)) } + .flowOn(dispatchers.IO) + .launchIn(viewModelScope) } internal companion object { @@ -207,6 +233,10 @@ class LoginViewModel @Inject constructor( ) ) } + + is Event.OnStoredAccountsChanged -> { state -> + state.copy(hasStoredAccounts = event.hasAccounts, storedAccountsChecked = true) + } } } } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt new file mode 100644 index 0000000000..045318f03d --- /dev/null +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelStateTest.kt @@ -0,0 +1,160 @@ +package com.flipcash.app.login.internal.accounts + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.auth.internal.accounts.AccountRecord +import com.flipcash.app.auth.internal.accounts.AccountStore +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.getcode.crypt.MnemonicPhrase +import com.getcode.opencode.controllers.TokenController +import com.getcode.opencode.managers.MnemonicManager +import com.getcode.util.resources.FakeResourceHelper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The state the screen's safety guard reads. `currentEntropy` being declared but never populated + * would leave `isCurrent` permanently false, which makes the signed-in account removable — so it is + * asserted here rather than left to the UI. + * + * Every record's derivation is made to fail, which keeps the test off real PBKDF2/SLIP-10 work and + * covers the other half of the contract at the same time: a record we cannot derive from still + * produces a row. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AccountSelectionViewModelStateTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val authManager: AuthManager = mock() + private val mnemonicManager: MnemonicManager = mock() + private val tokenController: TokenController = mock() + private val resources = FakeResourceHelper() + + /** Records only what was asked of it; the real store's semantics are covered by its own tests. */ + private class RecordingAccountStore(private var records: List) : AccountStore { + val deleted = mutableListOf() + override suspend fun all(): List = records + override suspend fun allIncludingDeleted(): List = records + override suspend fun upsert(entropy: String) = Unit + override suspend fun setDeleted(entropy: String, deleted: Boolean) { + this.deleted += entropy + records = records.filterNot { it.entropy == entropy } + } + override suspend fun clear() = Unit + } + + private fun record(entropy: String, creationDate: Long) = + AccountRecord(entropy = entropy, creationDate = creationDate, lastSeen = creationDate) + + private fun viewModel(store: AccountStore, current: String?, dispatchers: TestDispatchers): + AccountSelectionViewModel { + whenever(authManager.accounts).thenReturn(store) + whenever(authManager.currentEntropy).thenReturn(current) + whenever(mnemonicManager.fromEntropyBase64(any())) + .thenThrow(IllegalArgumentException("not a real seed")) + return AccountSelectionViewModel( + authManager = authManager, + mnemonicManager = mnemonicManager, + tokenController = tokenController, + resources = resources, + dispatchers = dispatchers, + ) + } + + @Test + fun `load marks which account is signed in`() = runTest(mainCoroutineRule.dispatcher) { + val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) + val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler)) + + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertEquals("b", state.currentEntropy) + assertEquals(listOf("a", "b"), state.accounts.map { it.entropy }) + assertTrue(state.accounts.all { it.balanceUnavailable }) + assertTrue(state.accounts.none { it.notFound }) + } + + @Test + fun `removal refuses the account the user is signed into`() = runTest(mainCoroutineRule.dispatcher) { + val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) + val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler)) + advanceUntilIdle() + + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountRemoved("b")) + advanceUntilIdle() + + assertEquals(emptyList(), store.deleted) + assertEquals(listOf("a", "b"), viewModel.stateFlow.value.accounts.map { it.entropy }) + } + + /** + * Selecting a row is the whole point of the screen, and it hands the account on in base58 + * while the store holds base64 — the one place a silent encoding swap would log the user into + * nothing. Asserted on the exact string the encoder returned. + */ + @Test + fun `selecting an account switches to its base58 seed`() = runTest(mainCoroutineRule.dispatcher) { + val phrase = MnemonicPhrase(MnemonicPhrase.Kind.L12, List(12) { "abandon" }) + whenever(authManager.accounts).thenReturn(RecordingAccountStore(listOf(record("a", 1_000L)))) + whenever(authManager.currentEntropy).thenReturn(null) + whenever(mnemonicManager.fromEntropyBase64(any())).thenReturn(phrase) + whenever(mnemonicManager.getEncodedBase58(phrase)).thenReturn("base58-of-a") + val viewModel = AccountSelectionViewModel( + authManager = authManager, + mnemonicManager = mnemonicManager, + tokenController = tokenController, + resources = resources, + dispatchers = TestDispatchers(testScheduler), + ) + advanceUntilIdle() + + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountSelected("a")) + advanceUntilIdle() + + verify(authManager).logoutAndSwitchAccount("base58-of-a") + } + + /** Selection logs the user out, so no other event on the screen may reach it. */ + @Test + fun `removal does not switch accounts`() = runTest(mainCoroutineRule.dispatcher) { + val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) + val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler)) + advanceUntilIdle() + + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountRemoved("a")) + advanceUntilIdle() + + verify(authManager, never()).logoutAndSwitchAccount(any()) + } + + @Test + fun `removal drops any other account`() = runTest(mainCoroutineRule.dispatcher) { + val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L))) + val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler)) + advanceUntilIdle() + + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountRemoved("a")) + advanceUntilIdle() + + assertEquals(listOf("a"), store.deleted) + assertEquals(listOf("b"), viewModel.stateFlow.value.accounts.map { it.entropy }) + } +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt new file mode 100644 index 0000000000..6e036de4c9 --- /dev/null +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.login.internal.accounts + +import com.getcode.crypt.MnemonicPhrase +import org.junit.Test +import kotlin.test.assertEquals + +class AccountSelectionViewModelTest { + + // MnemonicPhrase's real constructor is `(kind: Kind, words: List)`; the fixture words + // below aren't a real 12/24-word phrase, so `Kind.L12` is a placeholder — displayName only + // reads `words`. + private fun phrase(vararg words: String) = MnemonicPhrase(MnemonicPhrase.Kind.L12, words.toList()) + + @Test + fun `derives the display name from the first and last word`() { + val name = AccountSelectionViewModel.displayName( + phrase("apple", "banana", "cherry", "date", "elder") + ) + assertEquals("Apple ... Elder", name) + } + + @Test + fun `capitalises words that are already capitalised`() { + val name = AccountSelectionViewModel.displayName(phrase("Apple", "Elder")) + assertEquals("Apple ... Elder", name) + } + + @Test + fun `truncates an owner address in the middle`() { + val truncated = AccountSelectionViewModel.truncateAddress("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + assertEquals("ABCD...WXYZ", truncated) + } + + @Test + fun `leaves a short address alone`() { + assertEquals("ABCD", AccountSelectionViewModel.truncateAddress("ABCD")) + } +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelAccountsTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelAccountsTest.kt new file mode 100644 index 0000000000..68870ed590 --- /dev/null +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelAccountsTest.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.login.router + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The router asks two separate questions of this state: has the stored-account read come back, and + * did it find anything. Collapsing them is the cold-start bug — `hasStoredAccounts` is false before + * the read lands as well as after an empty one, so only `storedAccountsChecked` says which. + */ +class LoginViewModelAccountsTest { + + @Test + fun `the initial state is not yet an answer`() { + val state = LoginViewModel.State() + + assertFalse(state.storedAccountsChecked) + assertFalse(state.hasStoredAccounts) + } + + @Test + fun `records that the store has accounts`() { + val state = LoginViewModel.updateStateForEvent( + LoginViewModel.Event.OnStoredAccountsChanged(hasAccounts = true) + )(LoginViewModel.State()) + + assertTrue(state.hasStoredAccounts) + assertTrue(state.storedAccountsChecked) + } + + @Test + fun `an empty store is still an answer`() { + val state = LoginViewModel.updateStateForEvent( + LoginViewModel.Event.OnStoredAccountsChanged(hasAccounts = false) + )(LoginViewModel.State()) + + // Distinguishes an empty store from a read that has not come back. A reducer that derived + // `storedAccountsChecked` from `hasAccounts` would pass every other test here and still + // leave Log In waiting forever on someone with no stored accounts. + assertTrue(state.storedAccountsChecked) + assertFalse(state.hasStoredAccounts) + } +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt index 21f53e9211..842fc4589b 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.login.router import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager +import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.AccountController @@ -43,6 +44,7 @@ class LoginViewModelCreateAccountTest { private val authManager: AuthManager = mock() private val accounts: AccountController = mock() + private val accountStore: AccountStore = mockk(relaxed = true) private val resources = FakeResourceHelper() private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) @@ -53,6 +55,8 @@ class LoginViewModelCreateAccountTest { BottomBarManager.clear() mockkStatic(android.util.Base64::class) every { android.util.Base64.encodeToString(any(), any()) } answers { java.util.Base64.getEncoder().encodeToString(firstArg()) } + // LoginViewModel.init checks the stored account list on construction. + whenever(authManager.accounts).thenReturn(accountStore) } @After diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt index 6aa3fadc57..bc38c49a22 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.login.router import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager +import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.AccountController @@ -36,6 +37,7 @@ class LoginViewModelErrorTest { // Mockito for Result-returning methods (MockK double-boxes Result inline class) private val authManager: AuthManager = mock() private val accounts: AccountController = mock() + private val accountStore: AccountStore = mockk(relaxed = true) // MockK for everything else private val resources = FakeResourceHelper() @@ -49,6 +51,8 @@ class LoginViewModelErrorTest { // android.util.Base64 is stubbed in unit tests; mock it so encodeBase64() doesn't NPE mockkStatic(android.util.Base64::class) every { android.util.Base64.encodeToString(any(), any()) } answers { java.util.Base64.getEncoder().encodeToString(firstArg()) } + // LoginViewModel.init checks the stored account list on construction. + whenever(authManager.accounts).thenReturn(accountStore) } @After diff --git a/apps/flipcash/shared/authentication/build.gradle.kts b/apps/flipcash/shared/authentication/build.gradle.kts index 0362d2ab58..d0da4fab23 100644 --- a/apps/flipcash/shared/authentication/build.gradle.kts +++ b/apps/flipcash/shared/authentication/build.gradle.kts @@ -7,8 +7,8 @@ android { } dependencies { - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.play.auth) + implementation(libs.play.services.blockstore) + implementation(libs.kotlinx.coroutines.play.services) implementation(libs.androidx.datastore) implementation(project(":apps:flipcash:shared:appsettings")) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index b725f009f7..c307d0b837 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -2,6 +2,7 @@ package com.flipcash.app.auth import androidx.core.app.NotificationManagerCompat import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.auth.internal.credentials.LookupResult import com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager import com.flipcash.app.contacts.ContactCoordinator @@ -18,7 +19,6 @@ import com.flipcash.services.controllers.PushController import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.shared.authentication.BuildConfig -import com.getcode.crypt.MnemonicPhrase import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceManager import com.getcode.utils.TraceType @@ -41,6 +41,7 @@ import javax.inject.Singleton @Singleton class AuthManager @Inject constructor( private val credentialManager: PassphraseCredentialManager, + private val accountStore: AccountStore, private val userManager: UserManager, private val notificationManager: NotificationManagerCompat, private val accountController: AccountController, @@ -309,9 +310,13 @@ class AuthManager @Inject constructor( } - suspend fun selectAccount(): Result { - return credentialManager.selectCredential() - } + val accounts: AccountStore get() = accountStore + + /** + * The entropy of the signed-in account, or null when nobody is. The selection screen needs it + * to mark its own row and to refuse to remove the account underneath the user. + */ + val currentEntropy: String? get() = userManager.entropy suspend fun deleteAndLogout(): Result { //todo: add account deletion diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecord.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecord.kt new file mode 100644 index 0000000000..b6d88dfed5 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecord.kt @@ -0,0 +1,22 @@ +package com.flipcash.app.auth.internal.accounts + +/** + * One account in the durable list. Mirrors iOS's `AccountDescription`. + * + * Only the entropy is stored — the owner public key and the display name derive from it, which + * halves the per-record footprint. See the design doc for the 4KB budget. + */ +data class AccountRecord( + /** Base64-encoded 16-byte seed. */ + val entropy: String, + val creationDate: Long, + val lastSeen: Long, + /** Soft delete. `null` means active, matching iOS's `fetchActiveHistorical`. */ + val deletionDate: Long? = null, +) { + val isActive: Boolean get() = deletionDate == null + + // The default data class toString() would put a seed into any log line or crash report that + // touches a record. + override fun toString(): String = "AccountRecord(created=$creationDate, active=$isActive)" +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodec.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodec.kt new file mode 100644 index 0000000000..e4a38d9940 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodec.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.TraceType +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 +import com.getcode.utils.trace +import java.nio.ByteBuffer + +/** + * Packs the account list into the single Block Store entry. + * + * Layout: `[version:1][count:2][record × count]`, each record a fixed 40 bytes of + * `[entropy:16][creationDate:8][lastSeen:8][deletionDate:8]`, with `0` standing in for a null + * deletion date. That is 2003 bytes at the cap, roughly half of Block Store's 4KB per-entry + * limit, which leaves room for a format change. + * + * Every decode failure returns an empty list rather than throwing: a corrupt entry must not + * brick login, and the migration repopulates from the local DataStore. + */ +internal object AccountRecordCodec { + + private const val TAG = "BlockStore" + + const val VERSION: Byte = 1 + const val MAX_ACCOUNTS = 50 + + private const val ENTROPY_BYTES = 16 + private const val RECORD_BYTES = ENTROPY_BYTES + 8 + 8 + 8 + private const val HEADER_BYTES = 1 + 2 + + /** + * A record whose entropy is not a 16-byte seed is dropped rather than thrown on. The only way + * one reaches here is the legacy DataStore the migration reads, and no such record names a + * usable account — whereas throwing would take down the write that noticed it, and every + * later write on the same list with it. + */ + fun encode(records: List): ByteArray { + val encodable = cap(records).mapNotNull { record -> + val entropy = runCatching { record.entropy.decodeBase64() }.getOrNull() + if (entropy?.size != ENTROPY_BYTES) { + trace( + tag = TAG, + message = "Dropping a stored account whose entropy is not $ENTROPY_BYTES bytes", + type = TraceType.Error, + ) + null + } else { + record to entropy + } + } + + val buffer = ByteBuffer.allocate(HEADER_BYTES + encodable.size * RECORD_BYTES) + buffer.put(VERSION) + buffer.putShort(encodable.size.toShort()) + encodable.forEach { (record, entropy) -> + buffer.put(entropy) + buffer.putLong(record.creationDate) + buffer.putLong(record.lastSeen) + buffer.putLong(record.deletionDate ?: 0L) + } + return buffer.array() + } + + fun decode(bytes: ByteArray): List { + if (bytes.isEmpty()) return emptyList() + return runCatching { + val buffer = ByteBuffer.wrap(bytes) + if (buffer.get() != VERSION) return emptyList() + val count = buffer.short.toInt() + if (count < 0 || count > MAX_ACCOUNTS) return emptyList() + if (bytes.size != HEADER_BYTES + count * RECORD_BYTES) return emptyList() + + (0 until count).map { + val entropy = ByteArray(ENTROPY_BYTES).also(buffer::get) + AccountRecord( + entropy = entropy.encodeBase64(), + creationDate = buffer.long, + lastSeen = buffer.long, + deletionDate = buffer.long.takeIf { date -> date != 0L }, + ) + } + }.getOrElse { emptyList() } + } + + /** + * Keeps the [MAX_ACCOUNTS] most recently seen, preserving the caller's ordering. + * + * Assumes the caller holds no duplicates — entropy is the unique account identifier. Records + * are matched by structural equality, so an exact duplicate inside the kept window would + * collapse and return fewer than [MAX_ACCOUNTS]. + */ + fun cap(records: List): List { + if (records.size <= MAX_ACCOUNTS) return records + val keep = records.sortedByDescending { it.lastSeen }.take(MAX_ACCOUNTS).toSet() + return records.filter { it in keep } + } +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStore.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStore.kt new file mode 100644 index 0000000000..a3fbe41642 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStore.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.auth.internal.accounts + +/** + * The durable account list. Replaces the Google Password Manager half of + * [com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager], which could restore an + * account but never enumerate them. + * + * Writes are best effort. Play services being unavailable costs an account its place in the + * list, not its funds — the access key is still the user's backstop — and the failure is traced + * at the Block Store boundary. A mutation never rewrites a list it could not first read, so a + * failure can drop an addition but cannot lose an existing entry. + */ +interface AccountStore { + /** Active accounts, newest created first. Empty when the store is unavailable. */ + suspend fun all(): List + + /** Every record, including soft-deleted ones. */ + suspend fun allIncludingDeleted(): List + + /** Inserts the account, or bumps its `lastSeen` and undeletes it if it is already known. */ + suspend fun upsert(entropy: String) + + suspend fun setDeleted(entropy: String, deleted: Boolean) + + /** Wipes the entry. Account removal uses [setDeleted]; this is for a full reset. */ + suspend fun clear() +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigration.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigration.kt new file mode 100644 index 0000000000..c021418284 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigration.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.auth.internal.accounts + +/** + * Seeds Block Store from the pre-existing local DataStore. + * + * A condition rather than a one-shot upgrade hook: it runs on every launch and does nothing once + * the store is non-empty. Written that way it also recovers a corrupt or unreadable entry, which + * decodes as empty. + */ +internal object AccountStoreMigration { + suspend fun run(store: AccountStore, legacyEntropy: suspend () -> List) { + if (store.allIncludingDeleted().isNotEmpty()) return + legacyEntropy().forEach { store.upsert(it) } + } +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreModule.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreModule.kt new file mode 100644 index 0000000000..cfd88b05b2 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreModule.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.auth.internal.accounts + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AccountStoreModule { + + @Binds + @Singleton + abstract fun bindBlockStoreBytes(impl: PlayBlockStoreBytes): BlockStoreBytes + + companion object { + @Provides + @Singleton + fun provideAccountStore(bytes: BlockStoreBytes): AccountStore = + BlockStoreAccountStore(bytes) + } +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStore.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStore.kt new file mode 100644 index 0000000000..6c6e26866a --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStore.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The whole entry is rewritten on every mutation, so concurrent writers would otherwise lose an + * update. The mutex is the only thing standing between two coroutines and a clobbered list. + */ +internal class BlockStoreAccountStore( + private val bytes: BlockStoreBytes, + private val clock: () -> Long = System::currentTimeMillis, +) : AccountStore { + + private val mutex = Mutex() + + override suspend fun all(): List = + allIncludingDeleted().filter { it.isActive } + + override suspend fun allIncludingDeleted(): List = mutex.withLock { read() } + + override suspend fun upsert(entropy: String) = mutate { records -> + val now = clock() + val existing = records.firstOrNull { it.entropy == entropy } + if (existing == null) { + records + AccountRecord(entropy = entropy, creationDate = now, lastSeen = now) + } else { + records.map { record -> + if (record.entropy == entropy) { + record.copy(lastSeen = now, deletionDate = null) + } else { + record + } + } + } + } + + override suspend fun setDeleted(entropy: String, deleted: Boolean) = mutate { records -> + records.map { record -> + if (record.entropy == entropy) { + record.copy(deletionDate = if (deleted) clock() else null) + } else { + record + } + } + } + + override suspend fun clear() { + mutex.withLock { bytes.delete() } + } + + /** + * A failed read and an empty store are indistinguishable once decoded, so the write is + * skipped rather than guessed at. Writing anyway would replace however many accounts are + * really in the entry with the one record this mutation happens to produce. + */ + private suspend fun mutate(block: (List) -> List) { + mutex.withLock { + val current = bytes.read() ?: return@withLock + val written = bytes.write(AccountRecordCodec.encode(block(sorted(current)))) + if (!written) { + // The Block Store boundary traces the cause; this says what it cost. The list is + // unchanged, so the caller's account keeps whatever place it already had. + trace( + tag = TAG, + message = "Account list write did not land", + type = TraceType.Error, + ) + } + } + } + + /** Newest created first, matching how the selection screen lists them. */ + private suspend fun read(): List = sorted(bytes.read() ?: ByteArray(0)) + + private fun sorted(blob: ByteArray): List = + AccountRecordCodec.decode(blob).sortedByDescending { it.creationDate } + + private companion object { + const val TAG = "BlockStore" + } +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreBytes.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreBytes.kt new file mode 100644 index 0000000000..5610db854e --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreBytes.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.auth.internal.accounts + +/** + * A single keyed blob in a store that outlives the app sandbox. + * + * Exists so that everything above it is testable without Play services, and so that the one file + * touching `BlockstoreClient` stays small. + */ +internal interface BlockStoreBytes { + /** + * The stored blob, an empty array when nothing is stored, or `null` when the store could + * not be read. A caller about to rewrite the blob must treat `null` as "do not write" — + * folding it into "empty" would rewrite a list it never saw. + */ + suspend fun read(): ByteArray? + + /** Returns false when the write did not happen. */ + suspend fun write(bytes: ByteArray): Boolean + + suspend fun delete() +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt new file mode 100644 index 0000000000..c302302a46 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt @@ -0,0 +1,83 @@ +package com.flipcash.app.auth.internal.accounts + +import android.content.Context +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.DeleteBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.StoreBytesData +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Block Store keeps this blob in Play services' own directory rather than the app sandbox, so it + * survives uninstall when the user has Backup services on. + * + * Cloud backup is requested only when Play services reports end-to-end encryption is available, + * which needs a screen lock. The flag is recomputed on every write rather than cached, because + * leaving it unset deletes previously backed-up cloud data on the next sync. + * + * Every Play services failure is swallowed: no Play services means no durable list, not a broken + * login. + */ +@Singleton +internal class PlayBlockStoreBytes @Inject constructor( + @ApplicationContext context: Context, +) : BlockStoreBytes { + + private val client = Blockstore.getClient(context) + + override suspend fun read(): ByteArray? = runCatching { + val request = RetrieveBytesRequest.Builder() + .setKeys(listOf(KEY)) + .build() + client.retrieveBytes(request).await() + .blockstoreDataMap[KEY] + ?.bytes + ?: ByteArray(0) + }.getOrElse { error -> + trace(tag = TAG, message = "Block Store read failed", error = error, type = TraceType.Error) + null + } + + override suspend fun write(bytes: ByteArray): Boolean = runCatching { + val canEncrypt = runCatching { client.isEndToEndEncryptionAvailable.await() } + .getOrDefault(false) + + val data = StoreBytesData.Builder() + .setKey(KEY) + .setBytes(bytes) + .setShouldBackupToCloud(canEncrypt) + .build() + + client.storeBytes(data).await() + true + }.getOrElse { error -> + trace(tag = TAG, message = "Block Store write failed", error = error, type = TraceType.Error) + false + } + + override suspend fun delete() { + runCatching { + val request = DeleteBytesRequest.Builder() + .setKeys(listOf(KEY)) + .build() + client.deleteBytes(request).await() + }.onFailure { error -> + trace(tag = TAG, message = "Block Store delete failed", error = error, type = TraceType.Error) + } + } + + private companion object { + const val TAG = "BlockStore" + + /** + * One of Block Store's 16 entries. The whole list lives here so a mutation is one atomic + * write with no cross-entry consistency to manage. + */ + const val KEY = "com.flipcash.account.list" + } +} diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt index e54b229087..bfecc0d7cf 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt @@ -1,12 +1,6 @@ package com.flipcash.app.auth.internal.credentials import android.content.Context -import androidx.credentials.CreatePasswordRequest -import androidx.credentials.CredentialManager -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetPasswordOption -import androidx.credentials.PasswordCredential -import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.booleanPreferencesKey @@ -14,14 +8,12 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.auth.internal.accounts.AccountStore +import com.flipcash.app.auth.internal.accounts.AccountStoreMigration import com.flipcash.services.controllers.AccountController import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager -import com.getcode.crypt.MnemonicPhrase import com.getcode.ed25519.Ed25519 -import com.getcode.opencode.managers.MnemonicManager import com.getcode.opencode.model.core.ID import com.getcode.utils.base58 import com.getcode.utils.encodeBase64 @@ -35,7 +27,6 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withContext -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -44,8 +35,7 @@ class PassphraseCredentialManager @Inject constructor( @ApplicationContext private val context: Context, private val accountController: AccountController, private val userManager: UserManager, - private val mnemonicManager: MnemonicManager, - private val featureFlags: FeatureFlagController, + private val accountStore: AccountStore, private val dispatchers: DispatcherProvider, ) { companion object { @@ -61,10 +51,6 @@ class PassphraseCredentialManager @Inject constructor( booleanPreferencesKey("${accountId}_completedOnboarding") } - private val credentialManager = CredentialManager.create(context) - - private val credentialLookupCache = mutableMapOf() - private val dataScope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatchers.IO) private val storage = PreferenceDataStoreFactory.create( @@ -162,8 +148,7 @@ class PassphraseCredentialManager @Inject constructor( val accountId = Base58.decode(tempUserId).toList() - // Store credential - storeCredential(entropy, accountId) + accountStore.upsert(entropy) // Store metadata val metadata = AccountMetadata.createFromId(accountId, entropy, isUnregistered = true) @@ -199,6 +184,12 @@ class PassphraseCredentialManager @Inject constructor( } suspend fun lookup(): LookupResult { + AccountStoreMigration.run(accountStore) { + val selected = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() + ?: return@run emptyList() + listOfNotNull(storage.data.map { it[entropyKey(selected)] }.firstOrNull()) + } + val selectedAccountId = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() @@ -231,6 +222,14 @@ class PassphraseCredentialManager @Inject constructor( return LookupResult.NoAccountFound } + /** + * [fromSelection] gates the local-userId fast path only: a login started from the account list + * is already known to the backend under this entropy, so the cached id is not re-checked. + * + * It does not gate the account-list write. Every successful login records the account, whatever + * started it. Gating that on [fromSelection] meant a deeplink login — which passes it to skip + * the fast path — never appeared in the list, so switching accounts after one showed nothing. + */ suspend fun login( entropy: String, fromSelection: Boolean = false, @@ -240,35 +239,17 @@ class PassphraseCredentialManager @Inject constructor( val selectedMetadata = getSelectedMetadata() if (selectedMetadata != null && selectedMetadata.entropy == entropy) { + accountStore.upsert(entropy) storeMetadata(selectedMetadata, isSelected = true) userManager.set(selectedMetadata.id) return Result.success(selectedMetadata) } if (!fromSelection) { - // Check existing credential - val userId = getUserId(entropy) - val existingCredential = - credentialLookupCache[entropy] ?: getCredentialByEntropy(entropy, userId) - - if (existingCredential != null) { - val metadata = getMetadata(userId.orEmpty())?.copy(isUnregistered = false) - ?: AccountMetadata( - userId.orEmpty(), - entropy, - isUnregistered = false - ) - - storeMetadata(metadata, isSelected = true) - - credentialLookupCache.clear() - - return Result.success(metadata) - } - // Check fallback userId + val userId = getUserId(entropy) if (userId != null) { - storeCredential(entropy, Base58.decode(userId).toList()) + accountStore.upsert(entropy) storage.edit { it.remove(userIdKey(entropy)) } val metadata = AccountMetadata(userId, entropy, isUnregistered = false) @@ -289,9 +270,7 @@ class PassphraseCredentialManager @Inject constructor( val userIdBytes = backendResult.getOrNull()!! val userIdStr = userIdBytes.base58 - if (!fromSelection) { - storeCredential(entropy, userIdBytes) - } + accountStore.upsert(entropy) val metadata = AccountMetadata(userIdStr, entropy, isUnregistered = false) storeMetadata(metadata, isSelected = true) @@ -309,28 +288,6 @@ class PassphraseCredentialManager @Inject constructor( return Result.success(Unit) } - suspend fun selectCredential(): Result { - return try { - val request = GetCredentialRequest.Builder() - .addCredentialOption(GetPasswordOption()) - .build() - - val result = credentialManager.getCredential(context, request) - val credential = result.credential as PasswordCredential - val words = credential.password - .replace(Regex("(\\s)+"), " ") - .lowercase(Locale.getDefault()).split(" ") - val mnemonic = MnemonicPhrase.newInstance(words)!! - credentialLookupCache[mnemonic.wordString] = credential - Result.success(mnemonic) - } catch (e: Exception) { - when (e) { - is GetCredentialCancellationException -> Result.failure(SelectCredentialError.UserCancelled()) - else -> Result.failure(e) - } - } - } - private suspend fun storeMetadata(metadata: AccountMetadata, isSelected: Boolean) { storage.edit { preferences -> if (isSelected) { @@ -350,12 +307,6 @@ class PassphraseCredentialManager @Inject constructor( return AccountMetadata(accountId, entropy, isUnregistered) } - private suspend fun storeUserId(entropy: String, userId: String) { - storage.edit { preferences -> - preferences[userIdKey(entropy)] = userId - } - } - private suspend fun getUserId(entropy: String): String? { return storage.data.map { it[userIdKey(entropy)] }.firstOrNull() } @@ -381,101 +332,4 @@ class PassphraseCredentialManager @Inject constructor( userManager.set(userId) userManager.set(state) } - - // Retrieve credential by entropy using GetPasswordOption - private suspend fun getCredentialByEntropy( - entropy: String, - expectedUserId: String? = null - ): PasswordCredential? { - if (!featureFlags.get(FeatureFlag.CredentialManager)) return null - - val request = GetCredentialRequest.Builder() - .addCredentialOption( - GetPasswordOption( - allowedUserIds = setOf(mnemonicManager.fromEntropyBase64(entropy).toCredentialId()), - isAutoSelectAllowed = true - ) - ) - .build() - - return try { - val result = credentialManager.getCredential(context, request) - val phrase = mnemonicManager.fromEntropyBase64(entropy).wordString - val credential = result.credential - if (credential is PasswordCredential && credential.id == expectedUserId && (credential.password == phrase)) { - credential - } else { - null - } - } catch (e: Exception) { - null - } - - } - - private suspend fun storeCredential( - entropy: String, - userId: ID, - overwrite: Boolean = false, - ): Result { - if (!featureFlags.get(FeatureFlag.CredentialManager)) return Result.failure(Throwable("Credential manager is force disabled")) - - val id = userId.base58 - val phrase = mnemonicManager.fromEntropyBase64(entropy) - val credentialId = phrase.toCredentialId() - - if (!overwrite) { - // Check for existing credential - val request = GetCredentialRequest.Builder() - .addCredentialOption( - GetPasswordOption( - allowedUserIds = setOf(credentialId), - isAutoSelectAllowed = true - ) - ) - .build() - - try { - val result = credentialManager.getCredential(context, request) - val credential = result.credential - if (credential is PasswordCredential && credential.id == credentialId && credential.password == phrase.wordString) { - // Credential exists and is valid; no need to recreate - return Result.success(Unit) - } - } catch (e: Exception) { - // Credential not found or user canceled; proceed to create - } - } - - - // Credential doesn't exist; create it - val createRequest = CreatePasswordRequest( - id = credentialId, - password = phrase.wordString, - isAutoSelectAllowed = true - ) - return try { - credentialManager.createCredential(context, createRequest) - Result.success(Unit) - } catch (e: Exception) { - storeUserId(entropy, id) - Result.success(Unit) - } - } - - private fun MnemonicPhrase.toCredentialId(): String { - val sortedWords = words.sorted() - val selectedWords = listOf( - sortedWords[0], - sortedWords[5], - sortedWords[11] - ) - return selectedWords.joinToString(" ") { word -> - word.lowercase().replaceFirstChar { it.titlecase() } - } - } -} - -sealed class SelectCredentialError : Exception() { - class UserCancelled : SelectCredentialError() } \ No newline at end of file diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index e883733d94..841cd04344 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.auth import androidx.core.app.NotificationManagerCompat import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.auth.internal.credentials.AccountMetadata import com.flipcash.app.auth.internal.credentials.LookupResult import com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager @@ -52,6 +53,7 @@ class AuthManagerTest { private val testDispatcher get() = dispatchers.testDispatcher private val credentialManager: PassphraseCredentialManager = mockk(relaxed = true) + private val accountStore: AccountStore = mockk(relaxed = true) private val userManager: UserManager = mockk(relaxed = true) private val notificationManager: NotificationManagerCompat = mockk(relaxed = true) private val accountController: AccountController = mockk(relaxed = true) @@ -96,6 +98,7 @@ class AuthManagerTest { authManager = AuthManager( credentialManager = credentialManager, + accountStore = accountStore, userManager = userManager, notificationManager = notificationManager, accountController = accountController, diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodecTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodecTest.kt new file mode 100644 index 0000000000..c22556b3ca --- /dev/null +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodecTest.kt @@ -0,0 +1,95 @@ +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.encodeBase64 +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AccountRecordCodecTest { + + private fun entropy(seed: Int): String = Random(seed).nextBytes(16).encodeBase64() + + private fun record(seed: Int, lastSeen: Long = 1_000L, deleted: Long? = null) = + AccountRecord( + entropy = entropy(seed), + creationDate = 500L, + lastSeen = lastSeen, + deletionDate = deleted, + ) + + @Test + fun `round trips a single record`() { + val records = listOf(record(1)) + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `round trips a soft-deleted record`() { + val records = listOf(record(1, deleted = 9_999L)) + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `round trips a full list and preserves order`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS).map { record(it, lastSeen = it.toLong()) } + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `a full list fits well inside the block store entry`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS).map { record(it) } + assertTrue(AccountRecordCodec.encode(records).size <= 2048) + } + + @Test + fun `encoding over the cap evicts the least recently seen`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS + 3).map { record(it, lastSeen = it.toLong()) } + val decoded = AccountRecordCodec.decode(AccountRecordCodec.encode(records)) + + assertEquals(AccountRecordCodec.MAX_ACCOUNTS, decoded.size) + // lastSeen 1, 2 and 3 are the oldest, so they are the ones dropped. + assertTrue(decoded.none { it.lastSeen <= 3L }) + } + + @Test + fun `decodes empty bytes as an empty list`() { + assertEquals(emptyList(), AccountRecordCodec.decode(ByteArray(0))) + } + + @Test + fun `decodes an unknown version as an empty list`() { + val bytes = AccountRecordCodec.encode(listOf(record(1))) + bytes[0] = 99 + assertEquals(emptyList(), AccountRecordCodec.decode(bytes)) + } + + @Test + fun `decodes truncated bytes as an empty list`() { + val bytes = AccountRecordCodec.encode(listOf(record(1), record(2))) + assertEquals(emptyList(), AccountRecordCodec.decode(bytes.copyOf(bytes.size - 5))) + } + + /** + * Throwing here would cancel the write that noticed the bad record, and the flow collecting + * it, so the record goes and the rest of the list survives. + */ + @Test + fun `encoding drops a wrong-length entropy and keeps the rest`() { + val bad = AccountRecord( + entropy = "AA", // decodes to one byte, not sixteen + creationDate = 500L, + lastSeen = 1_000L, + ) + val good = record(1) + + val decoded = AccountRecordCodec.decode(AccountRecordCodec.encode(listOf(bad, good))) + + assertEquals(listOf(good), decoded) + } +} diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigrationTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigrationTest.kt new file mode 100644 index 0000000000..be0451f498 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigrationTest.kt @@ -0,0 +1,82 @@ +package com.flipcash.app.auth.internal.accounts + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AccountStoreMigrationTest { + + /** Records what the production code asks of the store, without Block Store or Play services. */ + private class RecordingAccountStore : AccountStore { + val records = mutableListOf() + override suspend fun all(): List = records.filter { it.isActive } + override suspend fun allIncludingDeleted(): List = records + override suspend fun upsert(entropy: String) { + if (records.none { it.entropy == entropy }) { + records += AccountRecord(entropy, creationDate = 1L, lastSeen = 1L) + } else { + // Mirrors BlockStoreAccountStore, which bumps lastSeen and undeletes on upsert + // of a known account. + records.replaceAll { record -> + if (record.entropy == entropy) { + record.copy(lastSeen = record.lastSeen + 1, deletionDate = null) + } else { + record + } + } + } + } + override suspend fun setDeleted(entropy: String, deleted: Boolean) { + records.replaceAll { record -> + if (record.entropy == entropy) record.copy(deletionDate = if (deleted) 2L else null) + else record + } + } + override suspend fun clear() = records.clear() + } + + @Test + fun `seeds an empty store from the legacy entropy`() = runTest { + val store = RecordingAccountStore() + + AccountStoreMigration.run(store, legacyEntropy = { listOf("legacy-entropy") }) + + assertEquals(listOf("legacy-entropy"), store.all().map { it.entropy }) + } + + @Test + fun `is a no-op when the store already has accounts`() = runTest { + val store = RecordingAccountStore() + store.upsert("existing") + + AccountStoreMigration.run(store, legacyEntropy = { listOf("legacy-entropy") }) + + assertEquals(listOf("existing"), store.all().map { it.entropy }) + } + + @Test + fun `is a no-op when there is no legacy entropy`() = runTest { + val store = RecordingAccountStore() + + AccountStoreMigration.run(store, legacyEntropy = { emptyList() }) + + assertTrue(store.all().isEmpty()) + } + + /** + * Pins the guard to `allIncludingDeleted()`. A store holding only a removed account has + * already run, so seeding it again would resurrect the account the user deleted. + */ + @Test + fun `is a no-op when the store holds only a deleted account`() = runTest { + val store = RecordingAccountStore() + store.upsert("removed") + store.setDeleted("removed", deleted = true) + + AccountStoreMigration.run(store, legacyEntropy = { listOf("removed") }) + + assertTrue(store.all().isEmpty()) + assertEquals(listOf("removed"), store.allIncludingDeleted().map { it.entropy }) + } +} diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStoreTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStoreTest.kt new file mode 100644 index 0000000000..4b2fd8f90f --- /dev/null +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStoreTest.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.encodeBase64 +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.random.Random +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// `encodeBase64` is a KMP actual over `android.util.Base64`. The module sets +// `unitTests.isReturnDefaultValues = true`, so on the plain JVM runner it returns null rather +// than throwing. Robolectric supplies the real implementation, as it does in +// `libs/encryption/utils`' own `Base64ExtensionsTest`. +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BlockStoreAccountStoreTest { + + /** + * Reads and writes fail independently, because Block Store's failure modes are not symmetric: + * a read can fail on its own, and that is the case that can cost the list its contents. + */ + private class FakeBlockStoreBytes(var stored: ByteArray? = null) : BlockStoreBytes { + var canRead = true + var canWrite = true + override suspend fun read(): ByteArray? = + if (canRead) stored ?: ByteArray(0) else null + override suspend fun write(bytes: ByteArray): Boolean { + if (!canWrite) return false + stored = bytes + return true + } + override suspend fun delete() { stored = null } + } + + private fun entropy(seed: Int): String = Random(seed).nextBytes(16).encodeBase64() + + private fun store( + bytes: FakeBlockStoreBytes = FakeBlockStoreBytes(), + clock: () -> Long = { 1_000L }, + ) = BlockStoreAccountStore(bytes, clock) + + @Test + fun `starts empty`() = runTest { + assertEquals(emptyList(), store().all()) + } + + @Test + fun `upsert inserts an account`() = runTest { + val store = store() + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(entropy(1), all.first().entropy) + assertEquals(1_000L, all.first().creationDate) + assertEquals(1_000L, all.first().lastSeen) + assertNull(all.first().deletionDate) + } + + @Test + fun `upsert of a known account bumps lastSeen and keeps creationDate`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.upsert(entropy(1)) + + now = 5_000L + val store = store(bytes) { now } + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(1_000L, all.first().creationDate) + assertEquals(5_000L, all.first().lastSeen) + } + + @Test + fun `setDeleted hides the account from all but keeps the record`() = runTest { + val store = store() + store.upsert(entropy(1)) + store.setDeleted(entropy(1), deleted = true) + + assertTrue(store.all().isEmpty()) + assertEquals(1, store.allIncludingDeleted().size) + } + + @Test + fun `upsert undeletes and preserves the original creationDate`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.let { + it.upsert(entropy(1)) + it.setDeleted(entropy(1), deleted = true) + } + + now = 9_000L + val store = store(bytes) { now } + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(1_000L, all.first().creationDate) + assertNull(all.first().deletionDate) + } + + @Test + fun `all returns newest created first`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.upsert(entropy(1)) + now = 2_000L + store(bytes) { now }.upsert(entropy(2)) + + assertEquals(listOf(entropy(2), entropy(1)), store(bytes).all().map { it.entropy }) + } + + @Test + fun `clear empties the store`() = runTest { + val bytes = FakeBlockStoreBytes() + val store = store(bytes) + store.upsert(entropy(1)) + store.clear() + + assertTrue(store.all().isEmpty()) + assertNull(bytes.stored) + } + + @Test + fun `degrades to empty when the store is unavailable`() = runTest { + val bytes = FakeBlockStoreBytes().apply { + canRead = false + canWrite = false + } + val store = store(bytes) + + store.upsert(entropy(1)) + + assertEquals(emptyList(), store.all()) + } + + /** + * The failure that costs accounts: a read fails while the write still lands. Rewriting the + * entry from an unread list would replace every account with the one this mutation produces. + */ + @Test + fun `leaves the stored list alone when the read fails and the write would succeed`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.upsert(entropy(1)) + now = 2_000L + store(bytes) { now }.upsert(entropy(2)) + val before = bytes.stored + + bytes.canRead = false + store(bytes).upsert(entropy(3)) + + bytes.canRead = true + assertContentEquals(before, bytes.stored) + assertEquals(listOf(entropy(2), entropy(1)), store(bytes).all().map { it.entropy }) + } +} diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManagerLoginTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManagerLoginTest.kt new file mode 100644 index 0000000000..50ab06f7d1 --- /dev/null +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManagerLoginTest.kt @@ -0,0 +1,68 @@ +package com.flipcash.app.auth.internal.credentials + +import com.flipcash.app.auth.internal.accounts.BlockStoreAccountStore +import com.flipcash.app.auth.internal.accounts.BlockStoreBytes +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.controllers.AccountController +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID +import com.getcode.utils.encodeBase64 +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import kotlin.random.Random +import kotlin.test.assertEquals + +/** + * `fromSelection` is set by every caller that already knows the entropy — the account list, and + * the deeplink path that borrows the flag to skip the cached-id fast path. Reading it as "this + * account is already in the list" is what left a deeplink login unlisted, so the store write must + * not depend on it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class PassphraseCredentialManagerLoginTest { + + private class FakeBlockStoreBytes : BlockStoreBytes { + private var stored: ByteArray = ByteArray(0) + override suspend fun read(): ByteArray = stored + override suspend fun write(bytes: ByteArray): Boolean { + stored = bytes + return true + } + + override suspend fun delete() { + stored = ByteArray(0) + } + } + + private val entropy = Random(7).nextBytes(16).encodeBase64() + private val userId: ID = List(32) { it.toByte() } + + private val accountStore = BlockStoreAccountStore(FakeBlockStoreBytes()) + private val accountController: AccountController = mockk(relaxed = true) + private val userManager: UserManager = mockk(relaxed = true) + + private fun manager() = PassphraseCredentialManager( + context = RuntimeEnvironment.getApplication(), + accountController = accountController, + userManager = userManager, + accountStore = accountStore, + dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()), + ) + + @Test + fun `a login records the account whether or not it came from the selection screen`() = runTest { + coEvery { accountController.login() } returns Result.success(userId) + + manager().login(entropy, fromSelection = true) + + assertEquals(listOf(entropy), accountStore.all().map { it.entropy }) + } +} diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 08703a32dc..2180e97528 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -33,16 +33,6 @@ sealed interface FeatureFlag { } val isOptionFlag: Boolean get() = options.isNotEmpty() - @FeatureFlagMarker - data object CredentialManager: FeatureFlag { - override val key: String = "credential_manager_enabled" - override val default: Boolean = false - override val launched: Boolean = false - override val visible: Boolean = true - override val persistLogOut: Boolean = true - override val onboarding: Boolean = true - } - @FeatureFlagMarker data object VibrateOnScan: FeatureFlag { override val key: String = "scan_debug_enabled" @@ -130,7 +120,6 @@ sealed interface FeatureFlag { val FeatureFlag<*>.title: String get() = when (this) { - is FeatureFlag.CredentialManager -> "Credential Manager" FeatureFlag.VibrateOnScan -> "Vibrate on Scan" FeatureFlag.TransactionDetails -> "Transaction Details" FeatureFlag.CoinbaseOnRampSandbox -> "Coinbase Onramp Sandbox" @@ -143,7 +132,6 @@ val FeatureFlag<*>.title: String val FeatureFlag<*>.message: String get() = when (this) { - FeatureFlag.CredentialManager -> "When enabled, you will gain the ability to utilize Google's Password Manager for storing and recovering access keys for easier login experience" FeatureFlag.VibrateOnScan -> "When enabled, the device will vibrate once to indicate that the camera has registered the code on the bill" FeatureFlag.TransactionDetails -> "When enabled, you'll gain the ability to view details of each transaction from the balance screen" FeatureFlag.CoinbaseOnRampSandbox -> "When enabled, Coinbase onramp purchases will use the sandbox environment for testing" diff --git a/docs/superpowers/plans/2026-09-18-blockstore-account-list.md b/docs/superpowers/plans/2026-09-18-blockstore-account-list.md new file mode 100644 index 0000000000..b096ad372b --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-blockstore-account-list.md @@ -0,0 +1,2058 @@ +# Block Store Account List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Android an account list that survives app uninstall, backed by Google Block Store, with an iOS-equivalent selection screen wired into both the login flow and an in-app switcher, replacing Google Credential Manager. + +**Architecture:** A pure codec packs account records into one 4KB Block Store entry. A thin `BlockStoreBytes` seam wraps Play services so everything above it is unit-testable. `AccountStore` sits on top and is what the rest of the app talks to, replacing the Google Password Manager half of `PassphraseCredentialManager` while its DataStore half stays as the session store. The selection screen fetches per-account balances concurrently through the existing `TokenController.fetchTokenAccounts`, which already takes an arbitrary owner cluster. + +**Tech Stack:** Kotlin, Hilt, Jetpack Compose, Navigation 3, `com.google.android.gms:play-services-auth-blockstore`, `kotlinx-coroutines-play-services`, JUnit 4 + Robolectric + MockK. + +**Design doc:** `docs/superpowers/specs/2026-09-18-blockstore-account-list-design.md` + +**Base branch:** `origin/code/cash`. Branch name: `feat/blockstore-account-list`. + +--- + +## File Structure + +**New — storage** (`apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/`) + +| File | Responsibility | +|---|---| +| `AccountRecord.kt` | The stored record. Data only. | +| `AccountRecordCodec.kt` | Pure bytes to records and back, plus the cap and eviction rule. No Android. | +| `BlockStoreBytes.kt` | Interface: read, write and delete a single keyed blob. The seam that makes everything above testable. | +| `PlayBlockStoreBytes.kt` | The only file that touches `BlockstoreClient`. Swallows Play services failures. | +| `AccountStore.kt` | Interface the app depends on. | +| `BlockStoreAccountStore.kt` | Record-level semantics: upsert, soft delete, ordering, mutex. | +| `AccountStoreMigration.kt` | Seeds the store from the legacy DataStore. | +| `AccountStoreModule.kt` | Hilt bindings. | + +**Modified** + +| File | Change | +|---|---| +| `apps/flipcash/app/src/main/res/xml/backup_rules.xml` | Exclude the credentials DataStore. | +| `apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml` | Same, both sections. | +| `gradle/libs.versions.toml` | Add the Block Store artifact. | +| `apps/flipcash/shared/authentication/build.gradle.kts` | Swap `androidx.credentials` for Block Store and coroutines-play-services. | +| `.../auth/internal/credentials/PassphraseCredentialManager.kt` | Delete the GPM half; call `AccountStore`. | +| `.../auth/AuthManager.kt` | Delete `selectAccount()`; expose the store. | +| `.../featureflags/FeatureFlag.kt` | Delete `CredentialManager`. | +| `.../login/internal/screens/SeedInputContent.kt` | Delete the restore FAB. | +| `.../advanced/internal/AdvancedFeatureMenuItems.kt` | Replace the staff `SwitchAccount` with a full menu item. | +| `.../advanced/internal/AdvancedFeaturesScreenViewModel.kt` | Delete the GPM switch handler. | +| `apps/flipcash/core/.../onboarding/OnboardingStep.kt` | Add `AccountSelection`. | +| `apps/flipcash/core/.../AppRoute.kt` | Add `Menu.AccountSelection`. | +| `.../login/router/LoginViewModel.kt` | Add `hasStoredAccounts` to state. | +| `.../login/OnboardingFlowScreen.kt` | Branch `login`, register the step, update the KDoc diagram. | +| `apps/flipcash/app/.../navigation/AppScreenContent.kt` | Register `Menu.AccountSelection`. | +| `ui/resources/src/main/res/values/strings-localized.xml` | New strings. | + +**New — UI** (`apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/`) + +| File | Responsibility | +|---|---| +| `AccountSelectionViewModel.kt` | List plus concurrent balance fetch. | +| `AccountRow.kt` | One row. | +| `AccountSelectionContent.kt` | Screen scaffold and list. | +| `../AccountSelectionScreen.kt` | Public entry point for the in-app switcher. | + +--- + +## Task 1: Stop leaking seeds into Google's backup + +Independent of everything else and worth landing first. `credentials.preferences_pb` holds base64 seeds in cleartext and is currently included in Auto Backup and device transfer. + +**Files:** +- Modify: `apps/flipcash/app/src/main/res/xml/backup_rules.xml` +- Modify: `apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml` + +- [ ] **Step 1: Read both files to see the existing exclusion style** + +```bash +cat apps/flipcash/app/src/main/res/xml/backup_rules.xml apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml +``` + +Expected: each excludes `datastore/beta-flags.preferences_pb`, `datastore/user-flag-overrides.preferences_pb` and `datastore/release-stage.preferences_pb`, with `domain="file"`. + +- [ ] **Step 2: Add the exclusion to `backup_rules.xml`** + +Inside ``, next to the existing three: + +```xml + +``` + +- [ ] **Step 3: Add the exclusion to both sections of `data_extraction_rules.xml`** + +That file has a `` and a `` section. Add the same line to **each**: + +```xml + +``` + +- [ ] **Step 4: Verify the app still assembles** + +Run: `./gradlew :apps:flipcash:app:processDebugMainManifest` +Expected: `BUILD SUCCESSFUL`. A malformed XML rules file fails here. + +- [ ] **Step 5: Commit** + +```bash +git add apps/flipcash/app/src/main/res/xml/backup_rules.xml apps/flipcash/app/src/main/res/xml/data_extraction_rules.xml +git commit -m "fix(auth): keep the credentials datastore out of Google backup" +``` + +Commit body: + +``` +It holds base64 seeds in cleartext and the rules only excluded the three +feature-flag datastores, so it was going to cloud backup and device transfer. +``` + +--- + +## Task 2: `AccountRecord` and the codec + +The whole list lives in one 4KB entry, so the encoding and the cap are the load-bearing part. Pure Kotlin, no Android, fully testable. + +**Files:** +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecord.kt` +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodec.kt` +- Test: `apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountRecordCodecTest.kt` + +- [ ] **Step 1: Write `AccountRecord`** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +/** + * One account in the durable list. Mirrors iOS's `AccountDescription`. + * + * Only the entropy is stored — the owner public key and the display name derive from it, which + * halves the per-record footprint. See the design doc for the 4KB budget. + */ +data class AccountRecord( + /** Base64-encoded 16-byte seed. */ + val entropy: String, + val creationDate: Long, + val lastSeen: Long, + /** Soft delete. `null` means active, matching iOS's `fetchActiveHistorical`. */ + val deletionDate: Long? = null, +) { + val isActive: Boolean get() = deletionDate == null +} +``` + +- [ ] **Step 2: Write the failing codec test** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.encodeBase64 +import org.junit.Test +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AccountRecordCodecTest { + + private fun entropy(seed: Int): String = Random(seed).nextBytes(16).encodeBase64() + + private fun record(seed: Int, lastSeen: Long = 1_000L, deleted: Long? = null) = + AccountRecord( + entropy = entropy(seed), + creationDate = 500L, + lastSeen = lastSeen, + deletionDate = deleted, + ) + + @Test + fun `round trips a single record`() { + val records = listOf(record(1)) + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `round trips a soft-deleted record`() { + val records = listOf(record(1, deleted = 9_999L)) + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `round trips a full list and preserves order`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS).map { record(it, lastSeen = it.toLong()) } + assertEquals(records, AccountRecordCodec.decode(AccountRecordCodec.encode(records))) + } + + @Test + fun `a full list fits well inside the block store entry`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS).map { record(it) } + assertTrue(AccountRecordCodec.encode(records).size <= 2048) + } + + @Test + fun `encoding over the cap evicts the least recently seen`() { + val records = (1..AccountRecordCodec.MAX_ACCOUNTS + 3).map { record(it, lastSeen = it.toLong()) } + val decoded = AccountRecordCodec.decode(AccountRecordCodec.encode(records)) + + assertEquals(AccountRecordCodec.MAX_ACCOUNTS, decoded.size) + // lastSeen 1, 2 and 3 are the oldest, so they are the ones dropped. + assertTrue(decoded.none { it.lastSeen <= 3L }) + } + + @Test + fun `decodes empty bytes as an empty list`() { + assertEquals(emptyList(), AccountRecordCodec.decode(ByteArray(0))) + } + + @Test + fun `decodes an unknown version as an empty list`() { + val bytes = AccountRecordCodec.encode(listOf(record(1))) + bytes[0] = 99 + assertEquals(emptyList(), AccountRecordCodec.decode(bytes)) + } + + @Test + fun `decodes truncated bytes as an empty list`() { + val bytes = AccountRecordCodec.encode(listOf(record(1), record(2))) + assertEquals(emptyList(), AccountRecordCodec.decode(bytes.copyOf(bytes.size - 5))) + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*AccountRecordCodecTest*'` +Expected: FAIL — `Unresolved reference: AccountRecordCodec`. + +- [ ] **Step 4: Write the codec** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.decodeBase64 +import com.getcode.utils.encodeBase64 +import java.nio.ByteBuffer + +/** + * Packs the account list into the single Block Store entry. + * + * Layout: `[version:1][count:2][record × count]`, each record a fixed 40 bytes of + * `[entropy:16][creationDate:8][lastSeen:8][deletionDate:8]`, with `0` standing in for a null + * deletion date. That is 2003 bytes at the cap, roughly half of Block Store's 4KB per-entry + * limit, which leaves room for a format change. + * + * Every decode failure returns an empty list rather than throwing: a corrupt entry must not + * brick login, and the migration repopulates from the local DataStore. + */ +internal object AccountRecordCodec { + + const val VERSION: Byte = 1 + const val MAX_ACCOUNTS = 50 + + private const val ENTROPY_BYTES = 16 + private const val RECORD_BYTES = ENTROPY_BYTES + 8 + 8 + 8 + private const val HEADER_BYTES = 1 + 2 + + fun encode(records: List): ByteArray { + val capped = cap(records) + val buffer = ByteBuffer.allocate(HEADER_BYTES + capped.size * RECORD_BYTES) + buffer.put(VERSION) + buffer.putShort(capped.size.toShort()) + capped.forEach { record -> + val entropy = record.entropy.decodeBase64() + require(entropy.size == ENTROPY_BYTES) { + "Entropy must be $ENTROPY_BYTES bytes, was ${entropy.size}" + } + buffer.put(entropy) + buffer.putLong(record.creationDate) + buffer.putLong(record.lastSeen) + buffer.putLong(record.deletionDate ?: 0L) + } + return buffer.array() + } + + fun decode(bytes: ByteArray): List { + if (bytes.isEmpty()) return emptyList() + return runCatching { + val buffer = ByteBuffer.wrap(bytes) + if (buffer.get() != VERSION) return emptyList() + val count = buffer.short.toInt() + if (count < 0 || count > MAX_ACCOUNTS) return emptyList() + if (bytes.size != HEADER_BYTES + count * RECORD_BYTES) return emptyList() + + (0 until count).map { + val entropy = ByteArray(ENTROPY_BYTES).also(buffer::get) + AccountRecord( + entropy = entropy.encodeBase64(), + creationDate = buffer.long, + lastSeen = buffer.long, + deletionDate = buffer.long.takeIf { date -> date != 0L }, + ) + } + }.getOrElse { emptyList() } + } + + /** Keeps the [MAX_ACCOUNTS] most recently seen, preserving the caller's ordering. */ + fun cap(records: List): List { + if (records.size <= MAX_ACCOUNTS) return records + val keep = records.sortedByDescending { it.lastSeen }.take(MAX_ACCOUNTS).toSet() + return records.filter { it in keep } + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*AccountRecordCodecTest*'` +Expected: PASS, 8 tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts +git commit -m "feat(auth): add the account record codec for the block store entry" +``` + +--- + +## Task 3: The Play services seam + +`BlockstoreClient` is final and awkward to fake, so one small interface keeps it out of every test above it. + +**Files:** +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreBytes.kt` +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt` +- Modify: `gradle/libs.versions.toml` +- Modify: `apps/flipcash/shared/authentication/build.gradle.kts` + +- [ ] **Step 1: Add the dependency to the version catalog** + +In `gradle/libs.versions.toml`, under `[versions]` alongside the other `play-services-*` entries: + +```toml +play-services-blockstore = "16.4.0" +``` + +Under `[libraries]`, next to the other `play-services-*` entries: + +```toml +play-services-blockstore = { module = "com.google.android.gms:play-services-auth-blockstore", version.ref = "play-services-blockstore" } +``` + +- [ ] **Step 2: Wire it into the module** + +In `apps/flipcash/shared/authentication/build.gradle.kts`, replace these two lines: + +```kotlin + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.auth) +``` + +with: + +```kotlin + implementation(libs.play.services.blockstore) + implementation(libs.kotlinx.coroutines.play.services) +``` + +Leave `implementation(libs.androidx.datastore)` and everything else as it is. + +- [ ] **Step 3: Verify the dependency resolves** + +Run: `./gradlew :apps:flipcash:shared:authentication:dependencies --configuration debugCompileClasspath` +Expected: the output contains `com.google.android.gms:play-services-auth-blockstore:16.4.0`. If that version does not resolve, use the newest published `play-services-auth-blockstore` and keep the rest of this task unchanged. + +The module will not compile until Task 6 removes the `androidx.credentials` imports from `PassphraseCredentialManager`. That is expected; Steps 4 and 5 below only add files. + +- [ ] **Step 4: Write the seam interface** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +/** + * A single keyed blob in a store that outlives the app sandbox. + * + * Exists so that everything above it is testable without Play services, and so that the one file + * touching `BlockstoreClient` stays small. + */ +internal interface BlockStoreBytes { + /** The stored blob, or `null` when nothing is stored or the store is unavailable. */ + suspend fun read(): ByteArray? + + /** Returns false when the write did not happen. */ + suspend fun write(bytes: ByteArray): Boolean + + suspend fun delete() +} +``` + +- [ ] **Step 5: Write the Play services implementation** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import android.content.Context +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.DeleteBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.StoreBytesData +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Block Store keeps this blob in Play services' own directory rather than the app sandbox, so it + * survives uninstall when the user has Backup services on. + * + * Cloud backup is requested only when Play services reports end-to-end encryption is available, + * which needs a screen lock. The flag is recomputed on every write rather than cached, because + * leaving it unset deletes previously backed-up cloud data on the next sync. + * + * Every Play services failure is swallowed: no Play services means no durable list, not a broken + * login. + */ +@Singleton +internal class PlayBlockStoreBytes @Inject constructor( + @ApplicationContext context: Context, +) : BlockStoreBytes { + + private val client = Blockstore.getClient(context) + + override suspend fun read(): ByteArray? = runCatching { + val request = RetrieveBytesRequest.Builder() + .setKeys(listOf(KEY)) + .build() + client.retrieveBytes(request).await() + .blockstoreDataMap[KEY] + ?.bytes + }.getOrElse { error -> + trace(tag = TAG, message = "Block Store read failed", error = error, type = TraceType.Error) + null + } + + override suspend fun write(bytes: ByteArray): Boolean = runCatching { + val canEncrypt = runCatching { client.isEndToEndEncryptionAvailable.await() } + .getOrDefault(false) + + val data = StoreBytesData.Builder() + .setKey(KEY) + .setBytes(bytes) + .setShouldBackupToCloud(canEncrypt) + .build() + + client.storeBytes(data).await() + true + }.getOrElse { error -> + trace(tag = TAG, message = "Block Store write failed", error = error, type = TraceType.Error) + false + } + + override suspend fun delete() { + runCatching { + val request = DeleteBytesRequest.Builder() + .setKeys(listOf(KEY)) + .build() + client.deleteBytes(request).await() + }.onFailure { error -> + trace(tag = TAG, message = "Block Store delete failed", error = error, type = TraceType.Error) + } + } + + private companion object { + const val TAG = "BlockStore" + + /** + * One of Block Store's 16 entries. The whole list lives here so a mutation is one atomic + * write with no cross-entry consistency to manage. + */ + const val KEY = "com.flipcash.account.list" + } +} +``` + +If `isEndToEndEncryptionAvailable` does not resolve as a property, it is a method on this version of the artifact — call `client.isEndToEndEncryptionAvailable().await()` instead. Everything else is unchanged. Confirm the `trace` signature against another caller in the module before relying on the named arguments above. + +- [ ] **Step 6: Commit** + +```bash +git add gradle/libs.versions.toml apps/flipcash/shared/authentication/build.gradle.kts apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts +git commit -m "feat(auth): add the block store byte seam" +``` + +--- + +## Task 4: `AccountStore` + +Record-level semantics on top of the codec and the seam. + +**Files:** +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStore.kt` +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStore.kt` +- Test: `apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/BlockStoreAccountStoreTest.kt` + +- [ ] **Step 1: Write the interface** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +/** + * The durable account list. Replaces the Google Password Manager half of + * [com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager], which could restore an + * account but never enumerate them. + */ +interface AccountStore { + /** Active accounts, newest created first. Empty when the store is unavailable. */ + suspend fun all(): List + + /** Every record, including soft-deleted ones. */ + suspend fun allIncludingDeleted(): List + + /** Inserts the account, or bumps its `lastSeen` and undeletes it if it is already known. */ + suspend fun upsert(entropy: String) + + suspend fun setDeleted(entropy: String, deleted: Boolean) + + /** Wipes the entry. Account removal uses [setDeleted]; this is for a full reset. */ + suspend fun clear() +} +``` + +- [ ] **Step 2: Write the failing test** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import com.getcode.utils.encodeBase64 +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// `encodeBase64` is a KMP actual over `android.util.Base64`. The module sets +// `unitTests.isReturnDefaultValues = true`, so on the plain JVM runner it returns null rather +// than throwing. Robolectric supplies the real implementation, as it does in +// `libs/encryption/utils`' own `Base64ExtensionsTest`. +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BlockStoreAccountStoreTest { + + private class FakeBlockStoreBytes(var stored: ByteArray? = null) : BlockStoreBytes { + var available = true + override suspend fun read(): ByteArray? = if (available) stored else null + override suspend fun write(bytes: ByteArray): Boolean { + if (!available) return false + stored = bytes + return true + } + override suspend fun delete() { stored = null } + } + + private fun entropy(seed: Int): String = Random(seed).nextBytes(16).encodeBase64() + + private fun store( + bytes: FakeBlockStoreBytes = FakeBlockStoreBytes(), + clock: () -> Long = { 1_000L }, + ) = BlockStoreAccountStore(bytes, clock) + + @Test + fun `starts empty`() = runTest { + assertEquals(emptyList(), store().all()) + } + + @Test + fun `upsert inserts an account`() = runTest { + val store = store() + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(entropy(1), all.first().entropy) + assertEquals(1_000L, all.first().creationDate) + assertEquals(1_000L, all.first().lastSeen) + assertNull(all.first().deletionDate) + } + + @Test + fun `upsert of a known account bumps lastSeen and keeps creationDate`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.upsert(entropy(1)) + + now = 5_000L + val store = store(bytes) { now } + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(1_000L, all.first().creationDate) + assertEquals(5_000L, all.first().lastSeen) + } + + @Test + fun `setDeleted hides the account from all but keeps the record`() = runTest { + val store = store() + store.upsert(entropy(1)) + store.setDeleted(entropy(1), deleted = true) + + assertTrue(store.all().isEmpty()) + assertEquals(1, store.allIncludingDeleted().size) + } + + @Test + fun `upsert undeletes and preserves the original creationDate`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.let { + it.upsert(entropy(1)) + it.setDeleted(entropy(1), deleted = true) + } + + now = 9_000L + val store = store(bytes) { now } + store.upsert(entropy(1)) + + val all = store.all() + assertEquals(1, all.size) + assertEquals(1_000L, all.first().creationDate) + assertNull(all.first().deletionDate) + } + + @Test + fun `all returns newest created first`() = runTest { + var now = 1_000L + val bytes = FakeBlockStoreBytes() + store(bytes) { now }.upsert(entropy(1)) + now = 2_000L + store(bytes) { now }.upsert(entropy(2)) + + assertEquals(listOf(entropy(2), entropy(1)), store(bytes).all().map { it.entropy }) + } + + @Test + fun `clear empties the store`() = runTest { + val bytes = FakeBlockStoreBytes() + val store = store(bytes) + store.upsert(entropy(1)) + store.clear() + + assertTrue(store.all().isEmpty()) + assertNull(bytes.stored) + } + + @Test + fun `degrades to empty when the store is unavailable`() = runTest { + val bytes = FakeBlockStoreBytes().apply { available = false } + val store = store(bytes) + + store.upsert(entropy(1)) + + assertEquals(emptyList(), store.all()) + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*BlockStoreAccountStoreTest*'` +Expected: FAIL — `Unresolved reference: BlockStoreAccountStore`. + +- [ ] **Step 4: Write the implementation** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The whole entry is rewritten on every mutation, so concurrent writers would otherwise lose an + * update. The mutex is the only thing standing between two coroutines and a clobbered list. + */ +@Singleton +internal class BlockStoreAccountStore @Inject constructor( + private val bytes: BlockStoreBytes, + private val clock: () -> Long = System::currentTimeMillis, +) : AccountStore { + + private val mutex = Mutex() + + override suspend fun all(): List = + allIncludingDeleted().filter { it.isActive } + + override suspend fun allIncludingDeleted(): List = mutex.withLock { read() } + + override suspend fun upsert(entropy: String) = mutate { records -> + val now = clock() + val existing = records.firstOrNull { it.entropy == entropy } + if (existing == null) { + records + AccountRecord(entropy = entropy, creationDate = now, lastSeen = now) + } else { + records.map { record -> + if (record.entropy == entropy) { + record.copy(lastSeen = now, deletionDate = null) + } else { + record + } + } + } + } + + override suspend fun setDeleted(entropy: String, deleted: Boolean) = mutate { records -> + records.map { record -> + if (record.entropy == entropy) { + record.copy(deletionDate = if (deleted) clock() else null) + } else { + record + } + } + } + + override suspend fun clear() { + mutex.withLock { bytes.delete() } + } + + private suspend fun mutate(block: (List) -> List) { + mutex.withLock { + val updated = block(read()) + bytes.write(AccountRecordCodec.encode(updated)) + } + } + + /** Newest created first, matching how the selection screen lists them. */ + private suspend fun read(): List = + AccountRecordCodec.decode(bytes.read() ?: ByteArray(0)) + .sortedByDescending { it.creationDate } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*BlockStoreAccountStoreTest*'` +Expected: PASS, 8 tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts +git commit -m "feat(auth): add the block store backed account store" +``` + +--- + +## Task 5: Hilt wiring + +**Files:** +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreModule.kt` + +- [ ] **Step 1: Write the module** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AccountStoreModule { + + @Binds + @Singleton + abstract fun bindBlockStoreBytes(impl: PlayBlockStoreBytes): BlockStoreBytes + + companion object { + @Provides + @Singleton + fun provideAccountStore(bytes: BlockStoreBytes): AccountStore = + BlockStoreAccountStore(bytes) + } +} +``` + +`BlockStoreAccountStore`'s second constructor parameter defaults to `System::currentTimeMillis`, so `@Provides` rather than `@Binds` keeps that default without Dagger needing to inject a clock. + +- [ ] **Step 2: Verify it compiles** + +Run: `./gradlew :apps:flipcash:shared:authentication:compileDebugKotlin` +Expected: compiles, or fails only on the `androidx.credentials` imports in `PassphraseCredentialManager`, which Task 6 removes. + +- [ ] **Step 3: Commit** + +```bash +git add apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreModule.kt +git commit -m "feat(auth): wire the account store through Hilt" +``` + +--- + +## Task 6: Replace Credential Manager inside `PassphraseCredentialManager` + +That class does two jobs: it is the local session store (selected account, per-account entropy, onboarding flags) and it is the GPM integration. Only the second goes. Every `storeCredential()` call becomes an `AccountStore.upsert()`, and the migration lands here too. + +**Files:** +- Create: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigration.kt` +- Modify: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt` +- Test: `apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/internal/accounts/AccountStoreMigrationTest.kt` + +- [ ] **Step 1: Write the failing test** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AccountStoreMigrationTest { + + /** Records what the production code asks of the store, without Block Store or Play services. */ + private class RecordingAccountStore : AccountStore { + val records = mutableListOf() + override suspend fun all(): List = records.filter { it.isActive } + override suspend fun allIncludingDeleted(): List = records + override suspend fun upsert(entropy: String) { + if (records.none { it.entropy == entropy }) { + records += AccountRecord(entropy, creationDate = 1L, lastSeen = 1L) + } + } + override suspend fun setDeleted(entropy: String, deleted: Boolean) { + records.replaceAll { record -> + if (record.entropy == entropy) record.copy(deletionDate = if (deleted) 2L else null) + else record + } + } + override suspend fun clear() = records.clear() + } + + @Test + fun `seeds an empty store from the legacy entropy`() = runTest { + val store = RecordingAccountStore() + + AccountStoreMigration.run(store, legacyEntropy = { listOf("legacy-entropy") }) + + assertEquals(listOf("legacy-entropy"), store.all().map { it.entropy }) + } + + @Test + fun `is a no-op when the store already has accounts`() = runTest { + val store = RecordingAccountStore() + store.upsert("existing") + + AccountStoreMigration.run(store, legacyEntropy = { listOf("legacy-entropy") }) + + assertEquals(listOf("existing"), store.all().map { it.entropy }) + } + + @Test + fun `is a no-op when there is no legacy entropy`() = runTest { + val store = RecordingAccountStore() + + AccountStoreMigration.run(store, legacyEntropy = { emptyList() }) + + assertTrue(store.all().isEmpty()) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*AccountStoreMigrationTest*'` +Expected: FAIL — `Unresolved reference: AccountStoreMigration`. + +- [ ] **Step 3: Write the migration** + +```kotlin +package com.flipcash.app.auth.internal.accounts + +/** + * Seeds Block Store from the pre-existing local DataStore. + * + * A condition rather than a one-shot upgrade hook: it runs on every launch and does nothing once + * the store is non-empty. Written that way it also recovers a corrupt or unreadable entry, which + * decodes as empty. + */ +internal object AccountStoreMigration { + suspend fun run(store: AccountStore, legacyEntropy: suspend () -> List) { + if (store.allIncludingDeleted().isNotEmpty()) return + legacyEntropy().forEach { store.upsert(it) } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest --tests '*AccountStoreMigrationTest*'` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Delete the Credential Manager code from `PassphraseCredentialManager`** + +Delete these members entirely: +- the `CredentialManager.create(context)` field +- `private val credentialLookupCache` (line ~66) +- `suspend fun selectCredential(): Result` (line ~312) +- `private suspend fun getCredentialByEntropy(...)` (line ~386) +- `private suspend fun storeCredential(...)` (line ~416) +- `private fun MnemonicPhrase.toCredentialId(): String` (line ~466) + +Delete these imports: + +```kotlin +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.GetCredentialCancellationException +``` + +Add: + +```kotlin +import com.flipcash.app.auth.internal.accounts.AccountStore +import com.flipcash.app.auth.internal.accounts.AccountStoreMigration +``` + +- [ ] **Step 6: Inject `AccountStore` and replace the three `storeCredential` call sites** + +Add the parameter to the constructor, after `mnemonicManager`: + +```kotlin + private val accountStore: AccountStore, +``` + +At line ~166 in `presentSaveOption()`, replace the `storeCredential(entropy, accountId)` call and its comment with: + +```kotlin + accountStore.upsert(entropy) +``` + +At lines ~271 and ~293 inside `login()`, replace each `storeCredential(entropy, ...)` call with the same line: + +```kotlin + accountStore.upsert(entropy) +``` + +At line ~252 the lookup reads `credentialLookupCache[entropy] ?: getCredentialByEntropy(entropy, userId)`, which no longer has a GPM source. The DataStore is now the only source, so delete that credential round-trip and use the entropy the surrounding code already holds. Read the enclosing function before editing — it must keep returning the same type. + +- [ ] **Step 7: Run the migration on init** + +`lookup()` is called by `AuthManager.init()` and is the natural place. At the top of `lookup()`, before anything else: + +```kotlin + AccountStoreMigration.run(accountStore) { + val selected = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() + ?: return@run emptyList() + listOfNotNull(storage.data.map { it[entropyKey(selected)] }.firstOrNull()) + } +``` + +Match the actual DataStore key names and accessor style used elsewhere in the file — the names above follow the existing pattern but are not verbatim. + +- [ ] **Step 8: Compile the module** + +Run: `./gradlew :apps:flipcash:shared:authentication:compileDebugKotlin` +Expected: `BUILD SUCCESSFUL`. Any remaining `androidx.credentials` reference is a missed deletion. + +- [ ] **Step 9: Run the module's full test suite** + +Run: `./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest` +Expected: PASS. `AuthManagerTest` mocks `PassphraseCredentialManager` with `mockk(relaxed = true)`, so the new constructor parameter does not break it. + +- [ ] **Step 10: Commit** + +```bash +git add apps/flipcash/shared/authentication +git commit -m "feat(auth): store the account list in block store instead of Google Password Manager" +``` + +Commit body: + +``` +Google Password Manager could restore an account but never enumerate them, so +it could not back a list. It also never shipped -- the flag gating it was never +switched on, so there is nothing to migrate out of it. The only migration is +local: seed the store from the existing DataStore entropy. +``` + +--- + +## Task 7: Delete the rest of Credential Manager + +**Files:** +- Modify: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt:312-314` +- Modify: `apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt:37,133,146` +- Modify: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt` +- Modify: `apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt:77-90` +- Modify: `apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt` +- Modify: `apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt:343-345` +- Modify: `apps/flipcash/core/src/main/res/values/strings.xml:81-82` + +- [ ] **Step 1: Delete `AuthManager.selectAccount()` and expose the store** + +Remove these three lines, and the `MnemonicPhrase` import if nothing else in the file uses it: + +```kotlin + suspend fun selectAccount(): Result { + return credentialManager.selectCredential() + } +``` + +Add `private val accountStore: AccountStore,` to the constructor, import `com.flipcash.app.auth.internal.accounts.AccountStore`, and expose it for the selection screen: + +```kotlin + val accounts: AccountStore get() = accountStore +``` + +- [ ] **Step 2: Delete the feature flag** + +In `FeatureFlag.kt`, delete the `CredentialManager` declaration (line ~37) and its two `when` branches — the display name at line ~133 and the description at line ~146. + +- [ ] **Step 3: Delete the restore FAB from `SeedInputContent`** + +Remove the whole `floatingActionButton = { ... }` argument to `CodeScaffold` (lines 100-135), along with: +- the `featureFlags` and `restoreEnabled` locals (lines 88-89) +- the `onRestore` parameter from both `SeedInputContent` overloads and the `onRestore = { viewModel.restoreAccount() }` argument at line 73 +- the now-unused imports: `FloatingActionButton`, `Icons`, `SettingsBackupRestore`, `CircleShape`, `ColorFilter`, `FeatureFlag`, `LocalFeatureFlags`, and `Row` and `Alignment` if nothing else in the file uses them — the bottom `Row` at line 167 does use both, so keep those two. + +Then delete `restoreAccount()` from `SeedInputViewModel`, along with its now-unused +`SelectCredentialError` and `BottomBarManager` imports if nothing else in the file uses them. + +That was the only caller of `SelectCredentialError` and of the two error strings, so they go +with it. In `PassphraseCredentialManager.kt`, delete lines 343-345: + +```kotlin +sealed class SelectCredentialError : Exception() { + class UserCancelled : SelectCredentialError() +} +``` + +In `apps/flipcash/core/src/main/res/values/strings.xml`, delete lines 81-82: + +```xml + Something went wrong + Failed to restore selected account. Please try again +``` + +- [ ] **Step 4: Delete the staff menu item and its handler** + +In `AdvancedFeatureMenuItems.kt`, delete the `SwitchAccount` object and its KDoc (lines 77-90), and the `FeatureFlag` and `StaffMenuItem` imports if nothing else in the file uses them. Task 11 adds a replacement `SwitchAccount` back as a `FullMenuItem`. + +In `AdvancedFeaturesScreenViewModel.kt`: +- remove `add(SwitchAccount)` from `FullMenuList` (line ~34) +- delete the `Event.OnSwitchAccountsClicked` handler (lines 88-103) and both event declarations, `OnSwitchAccountsClicked` and `OnSwitchAccountTo` (lines ~67-68) +- remove those two from the `when` at lines ~199-200 +- drop the now-unused `authManager` and `mnemonicManager` constructor parameters if nothing else in the class uses them — check first +- delete the matching `OnSwitchAccountTo` collector in `AdvancedFeaturesScreen.kt` (lines ~66-71) + +- [ ] **Step 5: Drop the dangling dependency declarations** + +`apps/flipcash/core` declares both credentials artifacts but no source file in that module +imports them, so they go with the rest. In `apps/flipcash/core/build.gradle.kts`, delete lines +24-25: + +```kotlin + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.auth) +``` + +Task 3 already removed the same two lines from the authentication module, which leaves the +catalog entries unreferenced. In `gradle/libs.versions.toml`, delete the `androidx-credentials` +entry under `[versions]` and both `androidx-credentials` and `androidx-credentials-play-auth` +under `[libraries]`. + +- [ ] **Step 6: Confirm nothing references Credential Manager** + +Run: + +```bash +grep -rnE "androidx[.-]credentials|(^|[^A-Za-z])CredentialManager|selectCredential|selectAccount\(\)" --include=*.kt --include=*.toml --include=*.kts apps services libs gradle +``` + +Expected: no output. Any hit is a missed deletion. + +`PassphraseCredentialManager` survives this task and contains `CredentialManager` as a +substring, which is what the `(^|[^A-Za-z])` guard excludes. A plain `CredentialManager` +pattern reports its four remaining references as failures. + +- [ ] **Step 7: Build the app** + +Run: `./gradlew :apps:flipcash:app:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "chore(auth): remove Google Credential Manager" +``` + +Commit body: + +``` +Never enabled -- the flag gating it stayed off and the only entry point was +staff-only, so there is no user state to preserve. +``` + +--- + +## Task 8: The selection view model + +Mirrors iOS's `AccountSelectionScreen.fetchBalances()`: a concurrent per-account fetch, each signed by that account's own owner key, with a "not found" state when the backend does not know the account. + +**Files:** +- Create: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt` +- Modify: `apps/flipcash/features/login/build.gradle.kts` +- Test: `apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt` + +- [ ] **Step 1: Add the module dependencies** + +Check what the login module already has: + +```bash +grep -n "shared:authentication\|services:opencode" apps/flipcash/features/login/build.gradle.kts +``` + +Add whichever of these is missing to `apps/flipcash/features/login/build.gradle.kts`: + +```kotlin + implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":services:opencode")) +``` + +`AccountStore` and `AccountRecord` live under `com.flipcash.app.auth.internal.accounts`. If Kotlin rejects the import, move `AccountStore.kt` and `AccountRecord.kt` up to `com.flipcash.app.auth.accounts` — they are the module's public surface and the `internal` package was the wrong home for them. Keep the codec, the seam, the migration and the implementation where they are, and update their imports. + +- [ ] **Step 2: Write the failing test** + +```kotlin +package com.flipcash.app.login.internal.accounts + +import com.getcode.crypt.MnemonicPhrase +import org.junit.Test +import kotlin.test.assertEquals + +class AccountSelectionViewModelTest { + + private fun phrase(vararg words: String) = MnemonicPhrase(words.toList()) + + @Test + fun `derives the display name from the first and last word`() { + val name = AccountSelectionViewModel.displayName( + phrase("apple", "banana", "cherry", "date", "elder") + ) + assertEquals("Apple ... Elder", name) + } + + @Test + fun `capitalises words that are already capitalised`() { + val name = AccountSelectionViewModel.displayName(phrase("Apple", "Elder")) + assertEquals("Apple ... Elder", name) + } + + @Test + fun `truncates an owner address in the middle`() { + val truncated = AccountSelectionViewModel.truncateAddress("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + assertEquals("ABCD...WXYZ", truncated) + } + + @Test + fun `leaves a short address alone`() { + assertEquals("ABCD", AccountSelectionViewModel.truncateAddress("ABCD")) + } +} +``` + +`MnemonicPhrase`'s constructor takes a list of words — confirm the parameter shape with `grep -rn "class MnemonicPhrase" libs/crypto` before writing, and adjust the `phrase` helper if it differs. + +- [ ] **Step 3: Run it to verify it fails** + +Run: `./gradlew :apps:flipcash:features:login:testDebugUnitTest --tests '*AccountSelectionViewModelTest*'` +Expected: FAIL — `Unresolved reference: AccountSelectionViewModel`. + +- [ ] **Step 4: Write the view model** + +```kotlin +package com.flipcash.app.login.internal.accounts + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.auth.internal.accounts.AccountRecord +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.crypt.DerivePath +import com.getcode.crypt.DerivedKey +import com.getcode.crypt.MnemonicPhrase +import com.getcode.opencode.controllers.TokenController +import com.getcode.opencode.managers.MnemonicManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.sum +import com.getcode.opencode.model.financial.usdf +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountSelectionViewModel @Inject constructor( + private val authManager: AuthManager, + private val mnemonicManager: MnemonicManager, + private val tokenController: TokenController, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + + /** + * One row. [balance] is null while the fetch is in flight; [notFound] is the terminal state + * for an account the backend does not know, matching iOS's "Not Found" badge. + */ + data class AccountUiModel( + val entropy: String, + val name: String, + val ownerAddress: String, + val creationDate: Long, + val balance: Fiat? = null, + val notFound: Boolean = false, + ) + + data class State( + val accounts: List = emptyList(), + val loading: Boolean = true, + val currentEntropy: String? = null, + ) + + sealed interface Event { + data class OnAccountsLoaded(val accounts: List) : Event + data class OnBalanceResolved(val entropy: String, val balance: Fiat) : Event + data class OnBalanceNotFound(val entropy: String) : Event + data class OnAccountSelected(val entropy: String) : Event + data class OnAccountRemoved(val entropy: String) : Event + } + + init { + viewModelScope.launch { load() } + + eventFlow + .filterIsInstance() + .onEach { event -> + authManager.accounts.setDeleted(event.entropy, deleted = true) + load() + } + .launchIn(viewModelScope) + } + + private suspend fun load() { + val records = authManager.accounts.all() + dispatchEvent(Event.OnAccountsLoaded(records.map { it.toUiModel() })) + fetchBalances(records) + } + + private fun AccountRecord.toUiModel(): AccountUiModel { + val mnemonic = mnemonicManager.fromEntropyBase64(entropy) + return AccountUiModel( + entropy = entropy, + name = displayName(mnemonic), + ownerAddress = truncateAddress(clusterFor(entropy).authorityPublicKey.base58()), + creationDate = creationDate, + ) + } + + private fun clusterFor(entropy: String): AccountCluster { + val mnemonic = mnemonicManager.fromEntropyBase64(entropy) + val authority = DerivedKey.derive(DerivePath.primary, mnemonic) + return AccountCluster.newInstance(authority = authority, token = Token.usdf) + } + + /** + * Each account is fetched with its own owner key as both the account owner and the requesting + * owner — a self-signed request, which is what makes this work while logged out. Rows fill in + * as results land; one account failing must not blank the others. + */ + private fun fetchBalances(records: List) { + viewModelScope.launch { + records.map { record -> + async { + val cluster = clusterFor(record.entropy) + tokenController.fetchTokenAccounts(cluster, tokenController) + .onSuccess { tokens -> + if (tokens.isEmpty()) { + dispatchEvent(Event.OnBalanceNotFound(record.entropy)) + } else { + dispatchEvent( + Event.OnBalanceResolved( + entropy = record.entropy, + balance = tokens.map { it.balance }.sum(), + ) + ) + } + } + .onFailure { dispatchEvent(Event.OnBalanceNotFound(record.entropy)) } + } + }.awaitAll() + } + } + + companion object { + /** iOS's `MnemonicPhrase.name`: first word, ellipsis, last word, both capitalised. */ + fun displayName(mnemonic: MnemonicPhrase): String = + listOf(mnemonic.words.first(), mnemonic.words.last()) + .joinToString(" ... ") { word -> + word.lowercase().replaceFirstChar { it.titlecase() } + } + + fun truncateAddress(address: String): String = + if (address.length <= 8) address + else "${address.take(4)}...${address.takeLast(4)}" + + private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnAccountsLoaded -> { state -> + state.copy(accounts = event.accounts, loading = false) + } + + is Event.OnBalanceResolved -> { state -> + state.copy( + accounts = state.accounts.map { + if (it.entropy == event.entropy) it.copy(balance = event.balance) else it + } + ) + } + + is Event.OnBalanceNotFound -> { state -> + state.copy( + accounts = state.accounts.map { + if (it.entropy == event.entropy) it.copy(notFound = true) else it + } + ) + } + + is Event.OnAccountSelected, + is Event.OnAccountRemoved -> { state -> state } + } + } + } +} +``` + +`tokens.map { it.balance }.sum()` uses the same `Iterable.sum()` that `SelectTokenViewModel.totalBalance` uses — summing unrounded per-token values so the total rounds once, matching iOS. Confirm `AccountCluster`'s authority-public-key property name against `UserManager.kt:133-136` and `AccountCluster` itself; the derivation there is the canonical one. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :apps:flipcash:features:login:testDebugUnitTest --tests '*AccountSelectionViewModelTest*'` +Expected: PASS, 4 tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/flipcash/features/login +git commit -m "feat(login): add the account selection view model" +``` + +--- + +## Task 9: The selection screen + +**Files:** +- Create: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt` +- Create: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt` +- Modify: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt` +- Modify: `ui/resources/src/main/res/values/strings-localized.xml` + +- [ ] **Step 1: Add the strings** + +In `ui/resources/src/main/res/values/strings-localized.xml`, alongside the existing `title_*` and `action_*` entries: + +```xml +Select Account +Enter a Different Access Key +Not Found +Created %1$s +Remove Account +Remove Account? +Make sure you have a backup of your Access Key before removing this account. +``` + +`title_switchAccounts` already exists in this file; reuse it for the in-app switcher rather than adding another. + +- [ ] **Step 2: Move the removal confirmation into the view model** + +The confirmation dialog needs `ResourceHelper`, which composables in this codebase do not carry — `AdvancedFeaturesScreenViewModel` shows the established shape. Add to `AccountSelectionViewModel`'s constructor: + +```kotlin + private val resources: ResourceHelper, +``` + +with `import com.getcode.util.resources.ResourceHelper`, and add to `init`, alongside the other collectors: + +```kotlin + eventFlow + .filterIsInstance() + .onEach { event -> + // Removal is soft -- the record keeps its creation date, so logging back into the + // same account restores it rather than starting over. The warning is iOS's. + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = resources.getString(R.string.prompt_title_removeAccount), + subtitle = resources.getString(R.string.prompt_description_removeAccount), + positiveText = resources.getString(R.string.action_removeAccount), + negativeText = resources.getString(R.string.action_cancel), + onPositive = { dispatchEvent(Event.OnAccountRemoved(event.entropy)) }, + ) + ) + } + .launchIn(viewModelScope) +``` + +Add the event: + +```kotlin + data class OnRemoveRequested(val entropy: String) : Event +``` + +and its no-op branch next to `OnAccountSelected` in the `when`. + +Copy the exact `BottomBarMessage` shape and the `R` import from `AdvancedFeaturesScreenViewModel` — the field names above follow that pattern but must match the real constructor, and the strings resolve through whichever `R` that file uses. + +- [ ] **Step 3: Write the row** + +```kotlin +package com.flipcash.app.login.internal.accounts + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.features.login.R +import com.getcode.theme.CodeTheme + +@Composable +internal fun AccountRow( + account: AccountSelectionViewModel.AccountUiModel, + isCurrent: Boolean, + relativeCreationDate: String, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + enabled = !isCurrent, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.grid.x4), + imageVector = Icons.Default.Check, + contentDescription = null, + tint = if (isCurrent) CodeTheme.colors.textMain else Color.Transparent, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = account.name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.subtitle_accountCreated, relativeCreationDate), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = account.ownerAddress, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + when { + account.notFound -> Text( + text = stringResource(R.string.subtitle_accountNotFound), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + account.balance != null -> Text( + text = account.balance.formatted(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } +} +``` + +`combinedClickable` is experimental — add `@OptIn(ExperimentalFoundationApi::class)` if the compiler asks for it. Check the `R` import other files in this module use (`SeedInputContent.kt` uses `com.flipcash.features.login.R`) and whether the new strings resolve through it. + +- [ ] **Step 4: Write the screen content** + +```kotlin +package com.flipcash.app.login.internal.accounts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.features.login.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +@Composable +internal fun AccountSelectionContent( + state: AccountSelectionViewModel.State, + onSelect: (String) -> Unit, + onRemove: (String) -> Unit, + onEnterAccessKey: () -> Unit, + modifier: Modifier = Modifier, + /** The in-app switcher has no onboarding flow to fall through to, so it hides the footer. */ + showEnterAccessKey: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x4), + text = stringResource(R.string.title_selectAccount), + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + + LazyColumn(modifier = Modifier.weight(1f)) { + items(state.accounts, key = { it.entropy }) { account -> + AccountRow( + account = account, + isCurrent = account.entropy == state.currentEntropy, + relativeCreationDate = formatRelative(account.creationDate), + onClick = { onSelect(account.entropy) }, + onLongClick = { onRemove(account.entropy) }, + ) + } + } + + if (showEnterAccessKey) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x4), + onClick = onEnterAccessKey, + text = stringResource(R.string.action_enterDifferentAccessKey), + buttonState = ButtonState.Subtle, + ) + } + } +} + +private fun formatRelative(epochMillis: Long): String = + android.text.format.DateUtils.getRelativeTimeSpanString(epochMillis).toString() +``` + +Check `CodeTheme.typography` for the right display style name and `ButtonState` for the right subtle variant before compiling — `displaySmall` and `Subtle` follow the codebase's naming but confirm them. If the repo already has a relative-date helper, prefer it over the local `formatRelative`: `grep -rn "getRelativeTimeSpanString" --include=*.kt libs ui apps`. + +- [ ] **Step 5: Verify it compiles** + +Run: `./gradlew :apps:flipcash:features:login:compileDebugKotlin` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/flipcash/features/login ui/resources +git commit -m "feat(login): add the account selection screen" +``` + +--- + +## Task 10: Route it into the login flow + +**Files:** +- Modify: `apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt` +- Modify: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt` +- Modify: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt` +- Test: `apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelAccountsTest.kt` + +- [ ] **Step 1: Add the step** + +In `OnboardingStep.kt`, directly after `SeedInput`: + +```kotlin + @Parcelize + @Serializable + data object AccountSelection : OnboardingStep +``` + +- [ ] **Step 2: Write the failing test** + +```kotlin +package com.flipcash.app.login.router + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LoginViewModelAccountsTest { + + @Test + fun `defaults to no stored accounts so the router falls through to seed entry`() { + assertFalse(LoginViewModel.State().hasStoredAccounts) + } + + @Test + fun `records that the store has accounts`() { + val state = LoginViewModel.updateStateForEvent( + LoginViewModel.Event.OnStoredAccountsChanged(hasAccounts = true) + )(LoginViewModel.State()) + + assertTrue(state.hasStoredAccounts) + } +} +``` + +`updateStateForEvent` lives in an `internal companion object` and the test is in the same module, so it is visible. + +- [ ] **Step 3: Run it to verify it fails** + +Run: `./gradlew :apps:flipcash:features:login:testDebugUnitTest --tests '*LoginViewModelAccountsTest*'` +Expected: FAIL — `Unresolved reference: hasStoredAccounts`. + +- [ ] **Step 4: Add the state and the event** + +In `LoginViewModel.State` (line 42), add a field: + +```kotlin + val hasStoredAccounts: Boolean = false, +``` + +In `Event` (line 50), add: + +```kotlin + data class OnStoredAccountsChanged(val hasAccounts: Boolean) : Event +``` + +In the `when` inside `updateStateForEvent` (line 171), add: + +```kotlin + is Event.OnStoredAccountsChanged -> { state -> + state.copy(hasStoredAccounts = event.hasAccounts) + } +``` + +At the end of the `init` block (after line 165), add: + +```kotlin + viewModelScope.launch { + dispatchEvent( + Event.OnStoredAccountsChanged(authManager.accounts.all().isNotEmpty()) + ) + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `./gradlew :apps:flipcash:features:login:testDebugUnitTest --tests '*LoginViewModelAccountsTest*'` +Expected: PASS, 2 tests. + +- [ ] **Step 6: Branch the router's login action** + +In `OnboardingFlowScreen.kt`, replace line 380: + +```kotlin + login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, +``` + +with: + +```kotlin + // Mirrors iOS's OnboardingViewModel.loginAction: the list when we have one, the + // access key field when we do not. + login = { + if (state.hasStoredAccounts) { + flowNavigator.navigateTo(OnboardingStep.AccountSelection) + } else { + flowNavigator.navigateTo(OnboardingStep.SeedInput) + } + }, +``` + +- [ ] **Step 7: Register the step** + +In `onboardingEntryProvider`, after the `OnboardingStep.SeedInput` entry: + +```kotlin + annotatedEntry(testTag = "account_selection_screen") { + AccountSelectionStepContent() + } +``` + +Match the exact `annotatedEntry` argument shape the neighbouring entries use — some pass a test tag, some do not. + +Then add the step content next to `SeedInputStepContent`: + +```kotlin +@Composable +private fun AccountSelectionStepContent() { + val viewModel: AccountSelectionViewModel = hiltViewModel() + val loginViewModel: LoginViewModel = hiltViewModel() + val flowNavigator = LocalFlowNavigator.current + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + AccountSelectionContent( + state = state, + // The store holds base64 entropy, and LogIn defaults to fromDeeplink = false, which is + // the base64 path. Do not pass fromDeeplink = true -- that branch base58-decodes. + onSelect = { entropy -> + loginViewModel.dispatchEvent(LoginViewModel.Event.LogIn(entropy)) + }, + onRemove = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnRemoveRequested(entropy)) + }, + onEnterAccessKey = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, + ) +} +``` + +Read `SeedInputStepContent` and `LoginStepContent` first: copy how they obtain `flowNavigator` and the `LoginViewModel`, and reuse the same `LoggedInSuccessfully` / `LoggedInRequiresPayment` collectors so a successful selection advances the flow, rather than writing new ones. + +- [ ] **Step 8: Update the flow diagram** + +The KDoc at the top of the file (around line 89) enumerates the onboarding paths and is the only map of this flow anyone reads. Path 2 currently starts: + +``` + * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) + * Start → SeedInput ──┬────────────→ Name² → Contacts¹ → Notifications → Home³ +``` + +Change it to show the branch: + +``` + * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput or AccountSelection) + * Start ─┬─ AccountSelection ─┬─────→ Name² → Contacts¹ → Notifications → Home³ + * │ (stored accounts)│ + * └─ SeedInput ────────┘ + * AccountSelection also falls through to SeedInput via "Enter a Different Access Key". +``` + +Read the surrounding diagram first and match its alignment and footnote markers. + +- [ ] **Step 9: Build** + +Run: `./gradlew :apps:flipcash:app:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 10: Commit** + +```bash +git add apps/flipcash +git commit -m "feat(login): route Log In to the account list when there is one" +``` + +Commit body: + +``` +Mirrors iOS's loginAction. An empty store keeps today's behaviour and goes +straight to the access key field. +``` + +--- + +## Task 11: The in-app switcher + +The switch machinery already works: `logoutAndSwitchAccount` parks the entropy, and `App.kt:319` consumes it after logout and restarts onboarding with it as the seed. Only the picker in front of it is new. + +**There is a latent encoding bug to fix here.** `App.kt` passes the parked entropy as `OnboardingFlow(seed = ...)`, which reaches `LoginViewModel.Event.LogIn(seed, fromDeeplink = true)` and is **base58-decoded** (`LoginViewModel.kt:102-130`). The deleted staff switcher passed `mnemonicManager.getEncodedBase64(it)`. That mismatch never fired because the feature was never enabled. Pass base58. + +**Files:** +- Modify: `apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt:297-315` +- Modify: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt` +- Create: `apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/AccountSelectionScreen.kt` +- Modify: `apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt:150` +- Modify: `apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt` +- Modify: `apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt` + +- [ ] **Step 1: Add the route** + +In `AppRoute.Menu`, after `DeviceLogs`: + +```kotlin + @Serializable + data object AccountSelection : Menu +``` + +Match the annotations the neighbouring members carry — some routes in that file are `@Parcelize` as well. + +- [ ] **Step 2: Handle selection in the view model** + +In `AccountSelectionViewModel.init`, alongside the other collectors: + +```kotlin + eventFlow + .filterIsInstance() + .onEach { event -> + val mnemonic = mnemonicManager.fromEntropyBase64(event.entropy) + // App.kt feeds this to OnboardingFlow(seed = ...), which base58-decodes it. + authManager.logoutAndSwitchAccount(mnemonicManager.getEncodedBase58(mnemonic)) + } + .launchIn(viewModelScope) +``` + +Confirm `getEncodedBase58`'s exact name and parameter on `MnemonicManager` before writing it. + +This handler only runs for the in-app switcher: the onboarding step content calls `LoginViewModel.Event.LogIn` directly and never dispatches `OnAccountSelected`, so the two paths stay separate. + +- [ ] **Step 3: Write the screen entry point** + +```kotlin +package com.flipcash.app.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.login.internal.accounts.AccountSelectionContent +import com.flipcash.app.login.internal.accounts.AccountSelectionViewModel + +/** + * The logged-in switcher. Selecting a row logs out and parks the entropy; `App.kt` picks it up + * once the logout lands and restarts onboarding with it. + * + * The "Enter a Different Access Key" footer is hidden here: from inside the app there is no + * onboarding flow to fall through to, and a logged-in user reaching for a new access key is + * logging out, which the menu already offers. + */ +@Composable +fun AccountSelectionScreen() { + val viewModel: AccountSelectionViewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + AccountSelectionContent( + state = state, + onSelect = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnAccountSelected(entropy)) + }, + onRemove = { entropy -> + viewModel.dispatchEvent(AccountSelectionViewModel.Event.OnRemoveRequested(entropy)) + }, + onEnterAccessKey = {}, + showEnterAccessKey = false, + ) +} +``` + +`AccountSelectionContent` is `internal`, and this file is in the same module, so it is visible. + +- [ ] **Step 4: Register the route** + +In `AppScreenContent.kt`, after line 150: + +```kotlin + annotatedEntry { AccountSelectionScreen() } +``` + +Add the `com.flipcash.app.login.AccountSelectionScreen` import, and check the app module already depends on the login feature: + +```bash +grep -n "features:login" apps/flipcash/app/build.gradle.kts +``` + +- [ ] **Step 5: Add the menu entry** + +In `AdvancedFeatureMenuItems.kt`, add a replacement for the item Task 7 deleted — a full menu item this time, with no feature flag: + +```kotlin +internal data object SwitchAccount : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) + override val name: String + @Composable get() = stringResource(R.string.title_switchAccounts) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.AccountSelection) +} +``` + +In `AdvancedFeaturesScreenViewModel.kt`, add `add(SwitchAccount)` back to `FullMenuList`, above `add(LogOut)`. This reuses the existing `OpenScreen` event, so no new view model plumbing is needed. + +- [ ] **Step 6: Build** + +Run: `./gradlew :apps:flipcash:app:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Run the tests for every touched module** + +```bash +./gradlew :apps:flipcash:shared:authentication:testDebugUnitTest :apps:flipcash:features:login:testDebugUnitTest :apps:flipcash:features:advanced:testDebugUnitTest +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add apps/flipcash +git commit -m "feat(menu): switch accounts from the account list" +``` + +Commit body: + +``` +The switch machinery already worked; only the picker in front of it is new. +Passes base58 entropy -- App.kt hands the parked value to OnboardingFlow as a +seed, which base58-decodes it, and the removed staff switcher passed base64. +``` + +--- + +## Task 12: Prove the premise on a device + +The unit tests cover the codec and the store's semantics. They cannot show that Block Store actually survives an uninstall, which is the entire reason for this work. + +- [ ] **Step 1: Install and create two accounts** + +```bash +./gradlew :apps:flipcash:app:installDebug +``` + +Log in to two different accounts so both are upserted. + +- [ ] **Step 2: Confirm the list shows both** + +Menu → Switch Account. Expected: both accounts, each with a name, a created date, a truncated owner address, and either a balance or "Not Found". + +- [ ] **Step 3: Confirm Backup services are on** + +On the device: Settings → Google → Backup. Expected: on. If it is off, turn it on before the next step — this is the condition the whole mechanism depends on. + +- [ ] **Step 4: Uninstall and reinstall** + +```bash +adb uninstall com.flipcash.app.android +``` + +```bash +./gradlew :apps:flipcash:app:installDebug +``` + +- [ ] **Step 5: Confirm the list survived** + +Open the app and tap Log In. Expected: the account list with both accounts, **not** the access key field. This is the check the whole design exists for. If the list is empty here, stop and diagnose before shipping — nothing else in this plan matters if this fails. + +- [ ] **Step 6: Confirm the documented degraded behaviour** + +Turn Backup services off and repeat Steps 4 and 5. Expected: the list is empty and Log In goes to the access key field, which is the documented behaviour rather than a bug. Confirm the app still logs in normally from there. + +- [ ] **Step 7: Open the PR** + +Write the body to a file first — inline `--body` gets mangled by the shell: + +```bash +cat > /tmp/blockstore-pr-body.md <<'BODY' +iOS keeps its account list in the keychain, which survives an app uninstall. +Android had no equivalent: the list lived in a DataStore that went with app +data, so reinstalling meant re-entering an access key. Block Store keeps bytes +in Play services' own directory rather than the app sandbox, which is the one +mechanism that gets the same outcome. + +The whole list is one 4KB Block Store entry -- 40 bytes per account, storing +only the entropy and three timestamps and deriving the owner key and display +name on read. Capped at 50 with least-recently-seen eviction, which uses about +half the budget. + +The selection screen mirrors iOS: per-account live balances fetched +concurrently, each request signed by that account's own owner key, a "Not +Found" badge when the backend does not know the account, and soft delete so a +later login restores the record rather than starting it over. It appears in +both places iOS puts it -- "Log In" routes to it when the store is non-empty, +and the menu opens it for a logged-in switch. + +Google Credential Manager is removed. It could restore an account but never +enumerate them, so it could not have backed a list, and it never shipped -- the +flag gating it stayed off and its only entry point was staff-only. Nothing to +migrate out of it; the one migration is local, seeding Block Store from the +existing DataStore entropy. + +Two fixes ride along: + +- `credentials.preferences_pb` holds base64 seeds in cleartext and was included + in Auto Backup and device transfer, because the rules only excluded the three + feature-flag datastores. Now excluded from both. +- The switch-account handoff passed base64 entropy to a path that base58-decodes + it. Never fired, because the feature was never enabled. Now passes base58. +BODY +``` + +```bash +gh pr create --base code/cash --assignee @me --title "feat(auth): back the account list with Block Store" --body-file /tmp/blockstore-pr-body.md +``` + +--- + +## Notes for the implementer + +**Where the spec's claims come from.** The 40-bytes-per-account figure and the 50-account cap are worked out in the design doc's "What gets stored". The balance approach reuses `TokenController.fetchTokenAccounts(cluster, metadataProvider)`, which already takes an arbitrary cluster and passes it as both owner and requester — that is the Android shape of iOS's `fetchPrimaryAccounts(owner:)`, and it is why Task 8 is short. + +**Things this plan asks you to confirm rather than assume.** Each is marked at the step where it matters: the Block Store artifact version, whether `isEndToEndEncryptionAvailable` is a property or a method, the `trace` signature, `MnemonicPhrase`'s constructor, `AccountCluster`'s authority-key property, `MnemonicManager.getEncodedBase58`, the `BottomBarMessage` field names, `CodeTheme.typography.displaySmall`, `ButtonState.Subtle`, the `annotatedEntry` argument shape, and the `DataStore` key accessors in `PassphraseCredentialManager`. These are small API confirmations against files the plan names, not open design questions. + +**Order matters in one place.** Task 3 leaves the authentication module uncompilable until Task 6 removes the `androidx.credentials` imports. Tasks 3 through 6 are one unit; do not stop between them expecting a green build. diff --git a/docs/superpowers/specs/2026-09-18-blockstore-account-list-design.md b/docs/superpowers/specs/2026-09-18-blockstore-account-list-design.md new file mode 100644 index 0000000000..8262c5d6bc --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-blockstore-account-list-design.md @@ -0,0 +1,238 @@ +# Block Store account list + +Status: design, not started. Target: `code/cash`. + +## The problem + +iOS keeps a list of every account the user has logged into, and `AccountSelectionScreen` +renders it so they can switch between them. The list lives in the keychain under +`com.flipcash.account.list` (`Flipcash/Keychain/Secure.swift`), which survives an app +uninstall because iOS leaves keychain items behind. Reinstall the app and the accounts are +still there. + +Android has no sandbox-local store with that property. Keystore keys are destroyed on +uninstall, and DataStore and EncryptedSharedPreferences go with app data. Today the account +list is a plaintext DataStore at `datastore/credentials.preferences_pb` +(`PassphraseCredentialManager`), keyed `{accountId}_entropy`, and it dies with the app. + +The one durable store we have built is Google Password Manager: `storeCredential()` writes +each account's mnemonic as a password credential, and `selectCredential()` opens the GPM +picker. It has never been switched on — `FeatureFlag.CredentialManager` gates it, the +`SwitchAccount` entry behind it is staff-only, and no user has gone through it. It could not +have carried the list anyway: `GetPasswordOption` needs `allowedUserIds` up front and there is +no enumerate API, so it restores *an* account but cannot enumerate them. + +## Why Block Store + +Block Store (`com.google.android.gms:play-services-auth-blockstore`) stores bytes in Google +Play services' private data directory rather than the app sandbox, keyed to package name and +signing certificate. Uninstalling the app does not remove them. The data comes back on +same-device reinstall, on device-to-device transfer, and on cloud restore after a factory +reset. + +Three limits shape the rest of this design: + +- **16 entries, 4KB each.** Not a per-account key space to spend freely. +- **Persistence depends on the user's Backup setting.** Settings > Google > Backup. With it + off, Block Store is local-only and an uninstall takes the list. +- **Cloud backup needs a screen lock.** `isEndToEndEncryptionAvailable()` requires API 29 and + a PIN, pattern or password. We are minSdk 29, so the API floor is not a constraint, but the + screen lock is. Cloud restore onto a non-Pixel target needs API 31. + +The screen-lock gate is the same bargain iOS makes with `.afterFirstUnlock`, so the security +posture is comparable rather than worse. + +## What gets stored + +One entry, under one key, holding the whole list. Per-account keys would cap us at 16 +accounts and introduce cross-entry consistency to manage; a single entry is one atomic read +and one atomic write, and leaves 15 entries for anything later. + +Each record is fixed-width binary: + +| Field | Bytes | Notes | +|---|---|---| +| `entropy` | 16 | The seed. Everything else derives from it. | +| `creationDate` | 8 | Epoch millis. | +| `lastSeen` | 8 | Epoch millis. Drives eviction; display sorts by `creationDate`. | +| `deletionDate` | 8 | Epoch millis, `0` for null. Soft delete, matching iOS. | + +40 bytes per account, plus a 1-byte format version and a 2-byte count. The 4KB entry holds +about 100 accounts. Cap the list at 50 and evict the oldest `lastSeen` on overflow; that +writes 2003 bytes, leaving half the budget as headroom for a format change. + +The owner public key and the display name are **not** stored. Both derive from the entropy +(`MnemonicManager.getKeyPair`, and iOS's `MnemonicPhrase.name` is first word + last word, +capitalised, joined by `...`). Deriving costs one SLIP-0010 + ed25519 pass per account at +list load, off the main thread, and the keypair is needed for the balance fetch anyway. + +Soft delete rather than hard delete, because iOS does: `setDeleted(ownerPublicKey:deleted:)` +sets a `deletionDate`, `fetchActiveHistorical()` filters on it, and a later login to the same +account clears it. Keeping the record means a user who removes an account and logs back in +does not lose its creation date. + +## Storage layer + +`AccountStore` in `apps/flipcash/shared/authentication`, alongside the code it replaces: + +```kotlin +interface AccountStore { + suspend fun all(): List // active, sorted by creationDate desc + suspend fun upsert(entropy: String) // inserts, or bumps lastSeen and undeletes + suspend fun setDeleted(entropy: String, deleted: Boolean) + suspend fun clear() +} +``` + +The Block Store implementation wraps `BlockstoreClient.retrieveBytes` / `storeBytes` / +`deleteBytes`. It gates `setShouldBackupToCloud(true)` on `isEndToEndEncryptionAvailable()`, +and leaves it unset otherwise — note that unsetting it deletes previously backed-up cloud +data on the next periodic sync, so the flag has to be recomputed on every write rather than +cached. + +When Play services are unavailable, every method degrades to a no-op returning an empty list. +The app must still log in and work; it just loses the durable list. + +Reads and writes go through a mutex. The whole entry is rewritten on each mutation, so two +concurrent writers would otherwise lose one of the two updates. + +## The selection screen + +`AccountSelectionScreen` in `features/login`, matching where iOS puts it and where +`SeedInputContent` already lives. + +Per row, mirroring iOS's `AccountRow`: + +- Check mark when the row is the account currently logged in, and the row is disabled. +- Derived name, `Firstword ... Lastword`. +- Live balance, right-aligned, or a "Not Found" badge when the backend does not know the + account. +- Created date, relative. +- Truncated owner public key, middle ellipsis. + +Below the list, "Enter a Different Access Key" falls through to `SeedInput`. + +Long-press replaces iOS's swipe and context menu: Remove Account (with the same confirmation +dialog warning about the access key backup), Copy Access Key, Copy Vault Address. + +### Balances + +iOS fetches a balance per account concurrently in `fetchBalances()`, using +`client.fetchPrimaryAccounts(owner:)` and `client.fetchMints(mints:)`, and catches +`ErrorFetchBalance.notFound` to set the badge. + +The Android equivalents exist: +`AccountController.getAccounts(accountOwner, requestingOwner, filter)` in +`services/opencode` (`GetTokenAccountInfos` underneath, same RPC as iOS), and +`CurrencyService.getMints(mintAddresses)`. Both take an `AccountCluster`, which we derive per +record from its entropy and pass as both `accountOwner` and `requestingOwner` — a +self-request, which is what iOS does by signing with the account's own owner keypair. + +This runs while logged out, against N owners the session is not authenticated as. Each +request is signed by the owner it asks about, which is what makes it acceptable to the +backend — the same property iOS relies on. + +Converting quarks to fiat goes through `VerifiedFiatCalculator.compute(amount, token, +balance, rate, trace)`, using the balance currency rate, matching iOS's +`ExchangedFiat.compute` with `supplyFromBonding` from the mint metadata. + +Rows render immediately with the list data and fill in balances as they arrive. One account's +failure must not blank the others. + +## Where it appears + +**Login.** iOS's `OnboardingViewModel.loginAction` checks `fetchActiveHistorical().isEmpty` +and routes to `.accountSelection` or `.login`. Android's equivalent is the `login` callback +the onboarding flow hands to `LoginRouterScreenContent`, which today is unconditionally +`flowNavigator.navigateTo(OnboardingStep.SeedInput)`. It becomes the same branch, against a +`hasStoredAccounts` field added to `LoginViewModel.State` — the router screen already collects +that state, so nothing new is plumbed through. + +Add `OnboardingStep.AccountSelection` alongside `SeedInput` and an `annotatedEntry` for it in +`OnboardingFlowScreen`. The KDoc flow diagram at the top of that file enumerates the +onboarding paths and has to gain the new step; it is the only map of this flow anyone reads. + +Selecting a row dispatches the existing `LoginViewModel.Event.LogIn(entropyB64)`, which +already routes through `FacilitateLogin` to `authManager.login(entropyB64, isFromSelection = +true)` — the same path deep links take. + +**In-app switching.** Add `AppRoute.Menu.AccountSelection` — a top-level route rather than an +`OnboardingStep`, since it is reached from the menu — and present the same composable. +Selection calls `authManager.logoutAndSwitchAccount(entropy)`, which sets +`pendingSwitchEntropy` for App.kt's auth guard to consume. That machinery already works; only +the account picker in front of it changes. + +The staff-gated `SwitchAccount` entry in `AdvancedFeatureMenuItems` and its +`FeatureFlag.CredentialManager` gate are deleted, along with the +`Event.OnSwitchAccountsClicked` handler in `AdvancedFeaturesScreenViewModel`. + +**Auto-login.** `AuthManager.init()` currently reads `credentialManager.lookup()`. That keeps +working off the local DataStore for the selected account. iOS additionally falls back to the +most recent historical account when `wasLoggedIn` is true, deliberately gated so the silent +login does not fire against an arbitrary entry and trip the OCP antispam guard +(`SessionAuthenticator.initializeState`). We are not adopting that fallback here — Android +has no `wasLoggedIn` equivalent, and adding one is a separate change with its own antispam +exposure. + +## Removing Credential Manager + +`PassphraseCredentialManager` currently mixes two concerns: it is the local account/session +store (selected account, entropy per account, onboarding and access-key flags) and it is the +GPM integration. Only the second goes away. + +Deleted: `selectCredential()`, `getCredentialByEntropy()`, `storeCredential()`, +`credentialLookupCache`, `MnemonicPhrase.toCredentialId()`, the `androidx.credentials` +dependencies, `FeatureFlag.CredentialManager`, and the restore FAB in `SeedInputContent`. +`AuthManager.selectAccount()` goes with them. + +Kept: the DataStore half, which is still how the app knows who is logged in and where +onboarding got to, and `AuthManager.presentCredentialStorage()`, which stays as the entry +point and only changes what it writes to. `AccountStore.upsert()` replaces `storeCredential()` +at all three of its call sites — once in `presentSaveOption()`, twice in `login()`. + +### Migration + +Because the flag was never on, no user has a credential in GPM and there is nothing to strand. +The removal is a straight deletion in one release rather than a staged retreat. + +The only migration is local: on every launch, if Block Store reads empty and the local +DataStore holds an account, seed Block Store from it. That picks up everyone logged in at +upgrade time, recovers the corrupt-entry case below, and is idempotent because a non-empty +Block Store skips it. + +## Backup rules + +`allowBackup="true"` and `backup_rules.xml` currently exclude only the three feature-flag +datastores, so `credentials.preferences_pb` — plaintext seeds — is going into Auto Backup and +device transfer today. That is an accidental persistence path, and once Block Store is the +deliberate one, it should not be a second undocumented channel with different rules and no +screen-lock gate. + +Add `datastore/credentials.preferences_pb` to the exclusions in both `backup_rules.xml` and +`data_extraction_rules.xml`. + +This is worth doing regardless of the rest of this design. + +## Failure modes + +| Condition | Behaviour | +|---|---| +| Play services missing or out of date | `AccountStore` is a no-op, list is empty, login falls through to seed entry. | +| User has Backup services off | Block Store still works locally; the list survives app updates but not uninstall. No in-app signal — the setting is not readable. | +| No screen lock | Local-only, no cloud backup. Survives reinstall, not a new device. | +| Entry corrupt or version unknown | Treat as empty rather than crashing; the migration then repopulates it from the local DataStore. | +| More than 50 accounts | Evict the oldest `lastSeen`. | + +## Testing + +- `AccountStore` encode/decode round-trip, including the 50-account cap, eviction order, + soft delete and undelete, and an unknown version byte. +- A fake `BlockstoreClient` for the store's behaviour, plus the no-op path when Play services + are absent. +- Migration: empty Block Store plus a populated legacy DataStore produces the right records + and is idempotent on second launch. +- `LoginViewModel.State.hasStoredAccounts` reflects the store, and the router's `login` + callback branches on it: empty goes to `SeedInput`, non-empty to the account list. +- Manual, on a device, and this is the only check that proves the premise: log in to two + accounts, uninstall, reinstall, confirm both are listed. Repeat with Backup services off to + confirm the documented behaviour is the behaviour we get. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 385fcc4aaa..8630cdcc15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,6 @@ androidx-activity = "1.13.0" androidx-annotation = "1.10.0" androidx-biometrics = "1.4.0-alpha07" androidx-camerax = "1.6.1" -androidx-credentials = "1.6.0" androidx-core = "1.19.0" androidx-datastore = "1.2.1" androidx-exif = "1.4.2" @@ -54,6 +53,7 @@ slf4j = "1.7.36" firebase-bom = "34.18.0" play-service-ml-barcode = "18.3.1" play-services-auth-api-phone = "18.3.1" +play-services-blockstore = "16.4.0" google-play-billing = "9.1.0" google-play-updates = "2.1.0" @@ -136,8 +136,6 @@ androidx-camerax-camera2 = { module = "androidx.camera:camera-camera2", version. androidx-camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camerax" } androidx-camerax-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camerax" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } -androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } -androidx-credentials-play-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } androidx-localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version = "1.1.0" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exif" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } @@ -241,6 +239,7 @@ firebase-perf = { module = "com.google.firebase:firebase-perf" } play-integrity = { module = "com.google.android.play:integrity", version = "1.6.0" } play-service-ml-barcode = { module = "com.google.android.gms:play-services-mlkit-barcode-scanning", version.ref = "play-service-ml-barcode" } play-services-auth-api-phone = { module = "com.google.android.gms:play-services-auth-api-phone", version.ref = "play-services-auth-api-phone" } +play-services-blockstore = { module = "com.google.android.gms:play-services-auth-blockstore", version.ref = "play-services-blockstore" } play-services-wallet = { module = "com.google.android.gms:play-services-wallet", version = "20.0.0" } # Google Play Billing diff --git a/maestro/blockstore_account_list.yaml b/maestro/blockstore_account_list.yaml new file mode 100644 index 0000000000..a0bb3e07b0 --- /dev/null +++ b/maestro/blockstore_account_list.yaml @@ -0,0 +1,38 @@ +appId: com.flipcash.app.android +name: "Account list — recorded on login" +tags: + - onboarding + - blockstore +--- +# `clearState` is `pm clear`, and Play services drops the Block Store entry with it ("Removed +# Blockstore data for upon clearing data"), so this run genuinely starts with an empty list. +# Surviving an uninstall is the separate claim, and blockstore_survives_reinstall.yaml makes it. +- runFlow: + file: helpers/launch_app.yaml + env: + clearAppState: "true" + +# Logging in is what records the account; logging out is what leaves the list as the only way back. +- runFlow: subflows/login.yaml +- runFlow: subflows/logout.yaml + +# Log In routes to the list when there is one, instead of straight to the access key field. +- tapOn: Log in +- extendedWaitUntil: + visible: + id: account_selection_screen + timeout: 15000 +- assertVisible: "Select an Account" +# The row itself. The display name comes from the seed, which the flow does not know, so the +# creation subtitle is what identifies a row generically. +- assertVisible: "Created .*" +# Onboarding keeps the footer: there is a flow to fall through to. +- assertVisible: "Enter a Different Access Key" + +# The back control is not decoration — without it this step is a dead end. +- tapOn: + id: action_back +- extendedWaitUntil: + visible: + id: login_screen + timeout: 10000 diff --git a/maestro/blockstore_survives_reinstall.yaml b/maestro/blockstore_survives_reinstall.yaml new file mode 100644 index 0000000000..9d8bf3200c --- /dev/null +++ b/maestro/blockstore_survives_reinstall.yaml @@ -0,0 +1,29 @@ +appId: com.flipcash.app.android +name: "Account list — survives uninstall" +tags: + - onboarding + - blockstore +--- +# The point of Block Store, and the reason this is not Credential Manager: the list outlives the +# app sandbox. Maestro has no uninstall command, so the run does it between the two flows: +# maestro test maestro/blockstore_account_list.yaml +# adb uninstall com.flipcash.app.android +# adb install apps/flipcash/app/build/outputs/apk/debug/app-debug.apk +# maestro test maestro/blockstore_survives_reinstall.yaml +# +# No `clearState` here — a fresh install has no state to clear, and clearing would hide a pass +# that only came from leftover app data. +- launchApp: + arguments: + isUiTest: true +- extendedWaitUntil: + visible: + id: login_screen + timeout: 30000 + +- tapOn: Log in +- extendedWaitUntil: + visible: + id: account_selection_screen + timeout: 15000 +- assertVisible: "Created .*" diff --git a/maestro/blockstore_switcher.yaml b/maestro/blockstore_switcher.yaml new file mode 100644 index 0000000000..934ec80165 --- /dev/null +++ b/maestro/blockstore_switcher.yaml @@ -0,0 +1,30 @@ +appId: com.flipcash.app.android +name: "Account list — in-app switcher" +tags: + - account + - blockstore +--- +- runFlow: subflows/login_with_deeplink.yaml +# Switch Accounts is a StaffMenuItem, so it is hidden until the account is staff or the beta +# override is on. The test account is neither, hence the unlock. +- runFlow: subflows/unlock_beta_features.yaml +- runFlow: subflows/navigate_to_advanced_features.yaml +- scrollUntilVisible: + element: "Switch Accounts" + direction: DOWN +- tapOn: "Switch Accounts" +- extendedWaitUntil: + visible: + id: account_selection_screen + timeout: 15000 +- assertVisible: "Select an Account" +- assertVisible: "Created .*" +# From inside the app there is no onboarding flow to fall through to, so the footer is hidden — +# which makes the back control the only way out. +- assertNotVisible: "Enter a Different Access Key" +- tapOn: + id: action_back +- extendedWaitUntil: + visible: + id: advanced_features_screen + timeout: 10000 diff --git a/maestro/subflows/unlock_beta_features.yaml b/maestro/subflows/unlock_beta_features.yaml new file mode 100644 index 0000000000..a6b246af16 --- /dev/null +++ b/maestro/subflows/unlock_beta_features.yaml @@ -0,0 +1,18 @@ +# Unlocks the staff/beta-only rows (Switch Accounts, and the rest) for a non-staff test account, +# the same way a human does it: seven taps on the version footer at the bottom of the menu. +# +# `betaFlags` as a launch argument only flips individual FeatureFlag keys; it does not set the +# beta override, which is what gates StaffMenuItem rows. This is the only route to that override. +appId: com.flipcash.app.android +--- +- runFlow: navigate_to_menu.yaml +- scrollUntilVisible: + element: + text: "Version .*" + direction: DOWN + timeout: 20000 +- repeat: + times: 7 + commands: + - tapOn: + text: "Version .*" diff --git a/ui/resources/src/main/res/values/strings-localized.xml b/ui/resources/src/main/res/values/strings-localized.xml index edadd5db20..3b58ba6e5d 100644 --- a/ui/resources/src/main/res/values/strings-localized.xml +++ b/ui/resources/src/main/res/values/strings-localized.xml @@ -24,6 +24,7 @@ Download It Now Enable Face ID Enable Touch ID + Enter a Different Access Key Exit Give Give Kin @@ -50,6 +51,7 @@ Receive Recover Existing Account Remind + Remove Account Remove Phone Number Remove Your Phone Number Request a Tip @@ -187,6 +189,7 @@ You can get back into this account using your Access Key You will not be notified of any new messages from %1$s. You can unmute at any time. Only accounts created through Code are currently supported. + Make sure you have a backup of your Access Key before removing this account. Your friends will no longer be able to find you with this phone number. You will be notified of all new messages from %1$s. You can mute at any time. You will not receive any messages from %1$s until you pay them again. @@ -199,6 +202,7 @@ Are you sure you want to log out? Mute %1$s? Not a Code Account + Remove Account? Are you sure? Unmute %1$s? Unsubscribe from %1$s? @@ -220,8 +224,12 @@ Verify your identity to view your Access Key. Tap the Google Lens icon to open the QR code to log into Code. Alternatively you can log in manually by entering the 12 words in the Code Log In screen. Warning! This image gives access to all the funds you have in Code. Do not share this image with anyone else. Keep it secure and safe. + Created %1$s + Not Found Code enables you to receive Kin by pointing your camera at the digital bill on another user\'s phone You need to turn on Camera in Settings to scan Codes + Balance Unavailable + Current account Authenticate to access Code. Buy Kin Buy Kin (Coming Soon) @@ -267,6 +275,7 @@ No Linked\nPhone Number You don\'t have a phone number linked to this Code account. Link one so that your friends can discover you. No network connection + No accounts saved on this device On Code Open the Code app and point your camera to grab this cash Organizing Your Contacts