diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06df6c0cf..998dacdac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: java-version: '17' - name: Run ktlint run: cd android && ./gradlew ktlintCheck + - name: Run detekt + run: cd android && ./gradlew detekt compile-android: runs-on: ubuntu-latest @@ -101,6 +103,9 @@ jobs: - name: Compile Android run: cd android && ./gradlew assembleDevDebug + - name: Run Android unit tests + run: cd android && ./gradlew testDevDebugUnitTest + compile-ios: runs-on: macos-26 env: @@ -145,6 +150,9 @@ jobs: - name: Compile iOS run: cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -arch arm64 build + - name: Run iOS unit tests + run: cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:CoveTests -parallel-testing-enabled NO test + test: strategy: matrix: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 19d511c6f..5ce2988ff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("com.android.compose.screenshot") + id("dev.detekt") id("org.jetbrains.kotlin.plugin.compose") id("org.jlleitschuh.gradle.ktlint") } @@ -104,6 +105,7 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20251224") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") @@ -161,3 +163,32 @@ ktlint { exclude("**/uniffi/**") } } + +val detektBaselineFile = file("$rootDir/detekt-baseline.xml") + +detekt { + toolVersion = "2.0.0-alpha.3" + config.setFrom(files("$rootDir/detekt.yml")) + buildUponDefaultConfig = true + baseline = detektBaselineFile + basePath.set(rootDir) + failOnSeverity = dev.detekt.gradle.extensions.FailOnSeverity.Warning +} + +tasks.withType().configureEach { + baseline.set(detektBaselineFile) + exclude("**/cove_core/**") + exclude("**/uniffi/**") + exclude("**/build/**") +} + +tasks.withType().configureEach { + baseline.set(detektBaselineFile) + exclude("**/cove_core/**") + exclude("**/uniffi/**") + exclude("**/build/**") +} + +tasks.named("detekt") { + dependsOn("detektDevDebug") +} diff --git a/android/app/src/androidTest/java/org/bitcoinppl/cove/flows/onboarding/OnboardingFullLaunchTest.kt b/android/app/src/androidTest/java/org/bitcoinppl/cove/flows/onboarding/OnboardingFullLaunchTest.kt index c9d0139c5..5ee41d54f 100644 --- a/android/app/src/androidTest/java/org/bitcoinppl/cove/flows/onboarding/OnboardingFullLaunchTest.kt +++ b/android/app/src/androidTest/java/org/bitcoinppl/cove/flows/onboarding/OnboardingFullLaunchTest.kt @@ -197,7 +197,7 @@ class OnboardingFullLaunchTest { .skipCloudBackupAfterImport() .acceptTermsAfterImport() .chooseNativeImportedWalletFromSelectionSheet() - .assertImportedMainnetWalletHasHistoryAndNoBitcoin() + .assertImportedMainnetWalletShowsHistory() } @Test diff --git a/android/app/src/androidTest/java/org/bitcoinppl/cove/test/FullLaunchRobot.kt b/android/app/src/androidTest/java/org/bitcoinppl/cove/test/FullLaunchRobot.kt index 5563d33ec..4883bd933 100644 --- a/android/app/src/androidTest/java/org/bitcoinppl/cove/test/FullLaunchRobot.kt +++ b/android/app/src/androidTest/java/org/bitcoinppl/cove/test/FullLaunchRobot.kt @@ -258,14 +258,16 @@ class FullLaunchOnboardingRobot( } knownEmptyMainnetMnemonic.forEachIndexed { index, word -> - device.waitUntilVisible(tag("hotWalletImport.word.${index + 1}")).text = word + val wordSelector = tag("hotWalletImport.word.${index + 1}") + device.waitUntilVisible(wordSelector).text = word + device.waitUntilTextEquals(wordSelector, word) } device.dismissKeyboardIfShown() device.clickUntilVisible( clickSelector = tag("hotWalletImport.import"), targetSelector = text("Protect this wallet with Cloud Backup?"), - timeoutMillis = 20_000, + timeoutMillis = 60_000, ) return this @@ -307,9 +309,14 @@ class FullLaunchOnboardingRobot( return this } - fun assertImportedMainnetWalletHasHistoryAndNoBitcoin(): FullLaunchOnboardingRobot { - device.waitUntilSendShowsNoFunds() + fun assertImportedMainnetWalletShowsHistory(): FullLaunchOnboardingRobot { + device.waitUntilVisible(text("Send"), timeoutMillis = 30_000) device.waitUntilVisible(text("Transactions"), timeoutMillis = 30_000) + device.waitUntilAnyVisible( + text("Sent"), + text("Received"), + timeoutMillis = 90_000, + ) return this } @@ -539,7 +546,7 @@ private fun UiDevice.advanceUntilVisible( advance: UiDevice.() -> Boolean = { false }, ): UiObject2 { val deadline = System.currentTimeMillis() + timeoutMillis - var didAdvance = false + var lastAdvanceAt = 0L while (System.currentTimeMillis() < deadline) { findObject(targetSelector.value)?.let { return it } @@ -550,8 +557,10 @@ private fun UiDevice.advanceUntilVisible( continue } - if (!didAdvance) { - didAdvance = advance() + val now = System.currentTimeMillis() + if (now - lastAdvanceAt >= 750) { + advance() + lastAdvanceAt = now } waitForIdle() Thread.sleep(250) @@ -614,7 +623,7 @@ private fun UiDevice.clickUntilVisible( val clickNode = tryScrollUntilSafelyClickable(clickSelector) if (clickNode?.isEnabled == true) { - clickCenter(clickNode) + clickNode.click() } Thread.sleep(500) @@ -753,26 +762,23 @@ private fun UiDevice.clickCenterIfVisible(selector: DescribedSelector): Boolean return false } -private fun UiDevice.waitUntilSendShowsNoFunds() { - val noFundsSelector = text("No funds available to send") - val initialScanIncompleteSelector = text("Initial Scan Incomplete") - val deadline = System.currentTimeMillis() + 90_000 +private fun UiDevice.waitUntilTextEquals( + selector: DescribedSelector, + expected: String, + timeoutMillis: Long = 5_000, +) { + val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { - clickCenter(waitUntilVisible(text("Send"), timeoutMillis = 30_000)) - - val sendUnavailable = waitUntilAnyVisible(noFundsSelector, initialScanIncompleteSelector, timeoutMillis = 10_000) - if (sendUnavailable.description == noFundsSelector.description) { + val node = findObject(selector.value) + if (node?.text == expected) { return } - waitUntilVisible(text("Can't send until initial scan completes.")) - clickCenter(waitUntilVisible(text("OK"))) - waitForIdle() - Thread.sleep(2_000) + Thread.sleep(100) } - error("Timed out waiting for Send to report no funds after scan completion\n${dumpWindowHierarchy()}") + error("Timed out waiting for ${selector.description} to equal \"$expected\"\n${dumpWindowHierarchy()}") } private class DescribedSelector( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AndroidManagerCache.kt b/android/app/src/main/java/org/bitcoinppl/cove/AndroidManagerCache.kt new file mode 100644 index 000000000..6bd288821 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/AndroidManagerCache.kt @@ -0,0 +1,194 @@ +package org.bitcoinppl.cove + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager +import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter +import org.bitcoinppl.cove_core.WalletMetadata +import org.bitcoinppl.cove_core.types.WalletId + +@Stable +@Suppress("InjectDispatcher", "TooGenericExceptionCaught", "TooManyFunctions") +internal class AndroidManagerCache( + private val mainScope: CoroutineScope, +) { + private val tag = "AppManager" + + internal var walletManager: WalletManager? by mutableStateOf(null) + private set + + internal var sendFlowManager: SendFlowManager? by mutableStateOf(null) + private set + + internal var coinControlManager: CoinControlManager? by mutableStateOf(null) + private set + + internal fun setWalletManager(manager: WalletManager) { + Log.d(tag, "setting wallet manager for wallet ${manager.id}") + walletManager = manager + } + + internal fun cachedWalletManager(id: WalletId): WalletManager? = + walletManager?.takeIf { it.id == id } + + internal fun walletMetadata( + id: WalletId, + wallets: List, + ): WalletMetadata? { + cachedWalletManager(id)?.walletMetadata?.let { return it } + return wallets.firstOrNull { it.id == id } + } + + internal fun getWalletManager(id: WalletId): WalletManager { + walletManager?.let { + if (it.id == id) { + Log.d(tag, "found and using wallet manager for $id") + return it + } + + // selecting a different wallet is the boundary for ending in-flight scans + Log.d(tag, "closing old wallet manager for ${it.id}") + clearWalletManager() + } + + Log.d(tag, "did not find wallet manager for $id, creating new: ${walletManager?.id}") + + return try { + val manager = WalletManager(id = id) + walletManager = manager + manager + } catch (e: Exception) { + Log.e(tag, "Failed to create wallet manager", e) + throw e + } + } + + internal fun getSendFlowManager( + wm: WalletManager, + presenter: SendFlowPresenter, + ): SendFlowManager { + sendFlowManager?.let { + if (it.id == wm.id) { + Log.d(tag, "found and using sendflow manager for ${wm.id}") + it.presenter = presenter + return it + } + + // close old manager before replacing + Log.d(tag, "closing old sendflow manager for ${it.id}") + clearSendFlowManager() + } + + Log.d(tag, "did not find SendFlowManager for ${wm.id}, creating new") + val manager = SendFlowManager(wm.newSendFlowManager(wm.balance), presenter) + sendFlowManager = manager + return manager + } + + internal fun setCoinControlManager(manager: CoinControlManager) { + coinControlManager = manager + } + + internal fun clearCoinControlManager(manager: CoinControlManager) { + if (coinControlManager === manager) { + coinControlManager = null + } + } + + internal fun reconcileAfterLabelImport(walletId: WalletId) { + mainScope.launch { + val refreshed = + runCatchingCancellable(tag, "failed to reconcile after label import") { + reconcileAfterLabelImportAndWait(walletId) + }.getOrDefault(false) + if (!refreshed) { + walletManager + ?.takeIf { it.id == walletId } + ?.notifyLabelRefreshFailed() + } + } + } + + internal suspend fun reconcileAfterLabelImportAndWait(walletId: WalletId): Boolean { + val refreshed = + walletManager + ?.takeIf { it.id == walletId } + ?.reconcileAfterLabelImportAndWait() + ?: false + + coinControlManager + ?.takeIf { it.id == walletId } + ?.reloadLabels() + + sendFlowManager + ?.takeIf { it.id == walletId } + ?.reconcileAfterLabelImport() + + return refreshed + } + + internal fun clearWalletManager() { + clearWalletScopedChildManagers() + + try { + walletManager?.close() + } catch (e: Exception) { + Log.w(tag, "Error closing WalletManager: ${e.message}") + } + walletManager = null + } + + internal fun clearWalletManager(id: WalletId) { + if (walletManager?.id == id) { + clearWalletManager() + } + + if (sendFlowManager?.id == id) { + clearSendFlowManager() + } + } + + private fun clearWalletScopedChildManagers() { + clearSendFlowManager() + clearActiveCoinControlManager() + } + + private fun clearSendFlowManager() { + try { + sendFlowManager?.close() + } catch (e: Exception) { + Log.w(tag, "Error closing SendFlowManager: ${e.message}") + } + sendFlowManager = null + } + + private fun clearActiveCoinControlManager() { + try { + coinControlManager?.close() + } catch (e: Exception) { + Log.w(tag, "Error closing CoinControlManager: ${e.message}") + } + coinControlManager = null + } + + internal fun clearInactiveSendFlowManager(router: RouterManager) { + val manager = sendFlowManager ?: return + if (routeStackContainsSendWallet(router.default, router.routes, manager.id)) return + + clearSendFlowManager() + } + + internal fun refreshFiatValuesForCachedWallet(scope: CoroutineScope) { + walletManager?.let { wm -> + scope.launch(Dispatchers.IO) { + wm.forceWalletScan() + wm.updateWalletBalance() + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index 9ac948502..c9b708245 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.bitcoinppl.cove.cloudbackup.CloudBackupManager @@ -19,12 +18,8 @@ import org.bitcoinppl.cove_core.device.KeychainException import org.bitcoinppl.cove_core.tapcard.* import org.bitcoinppl.cove_core.types.* import org.bitcoinppl.cove_core.util.GenerationToken -import org.bitcoinppl.cove_core.util.GenerationTracker -import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean -private class RouteUpdateDispatchException(cause: Throwable) : Exception("Unable to dispatch route update", cause) - /** * central app state manager (singleton) * holds the FfiApp instance, router, and global state @@ -36,9 +31,6 @@ class AppManager private constructor() : FfiReconcile { // Scope for UI-bound work; reconcile() hops to Main here private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val navigationGenerations = GenerationTracker() - private var pendingSidebarNavigationJob: Job? = null - private var navigationSettleJob: Job? = null // rust bridge - not observable private var rust: FfiApp = FfiApp() @@ -53,7 +45,56 @@ class AppManager private constructor() : FfiReconcile { Log.w(tag, it) } - var router: RouterManager = RouterManager(rust.state().router) + private val routerHost = + object : RouterManagerHost { + override fun canPopRoute(): Boolean = + withRustOr(false) { + canGoBack() + } + + override fun dispatchRouteUpdate(routes: List): Result = + dispatchResult(AppAction.UpdateRoute(routes)) + + override fun resetDefaultRouteTo(route: Route) { + withRustOr(Unit) { + resetDefaultRouteTo(route) + } + } + + override fun resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: List) { + withRustOr(Unit) { + resetNestedRoutesTo(defaultRoute, nestedRoutes) + } + } + + override fun loadAndResetDefaultRoute(route: Route) { + withRustOr(Unit) { + loadAndResetDefaultRoute(route) + } + } + + override fun resetAfterLoading(routes: List) { + withRustOr(Unit) { + resetAfterLoading(routes) + } + } + + override fun onRoutesChanged() { + clearInactiveSendFlowManager() + } + + override suspend fun startWalletScanIfNeeded(walletId: WalletId): Result = + runCatching { + getWalletManager(walletId).startWalletScanIfNeeded() + }.also { result -> + val error = result.exceptionOrNull() + if (error is CancellationException) { + throw error + } + } + } + + var router: RouterManager = RouterManager(rust.state().router, mainScope, routerHost) private set var database: Database = Database() @@ -63,11 +104,14 @@ class AppManager private constructor() : FfiReconcile { var wallets by mutableStateOf(emptyList()) private set - var isSidebarVisible by mutableStateOf(false) - internal set + var isSidebarVisible: Boolean + get() = router.isSidebarVisible + internal set(value) { + router.isSidebarVisible = value + } - var isNavigationSettled by mutableStateOf(true) - private set + val isNavigationSettled: Boolean + get() = router.isNavigationSettled var isLoading by mutableStateOf(false) @@ -98,23 +142,24 @@ class AppManager private constructor() : FfiReconcile { var fees: FeeResponse? by mutableStateOf(runCatching { rust.fees() }.getOrNull()) private set - // route id changes when route is reset, to clear lifecycle view state - var routeId by mutableStateOf(UUID.randomUUID().toString()) - private set + val routeId: String + get() = router.routeId // tracks whether async runtime has been initialized var asyncRuntimeReady by mutableStateOf(false) // multiple screens within the same wallet (send, coin control, tx details, settings) // call getWalletManager, this avoids recreating the actor and reconciler each time - internal var walletManager: WalletManager? = null - private set + private val managerCache = AndroidManagerCache(mainScope) - internal var sendFlowManager: SendFlowManager? = null - private set + internal val walletManager: WalletManager? + get() = managerCache.walletManager - internal var coinControlManager: CoinControlManager? = null - private set + internal val sendFlowManager: SendFlowManager? + get() = managerCache.sendFlowManager + + internal val coinControlManager: CoinControlManager? + get() = managerCache.coinControlManager val cloudBackupManager: CloudBackupManager = CloudBackupManager.getInstance() @@ -150,170 +195,39 @@ class AppManager private constructor() : FfiReconcile { /** * set the cached wallet manager instance */ - internal fun setWalletManager(manager: WalletManager) { - Log.d(tag, "setting wallet manager for wallet ${manager.id}") - walletManager = manager - } + internal fun setWalletManager(manager: WalletManager) = managerCache.setWalletManager(manager) - fun cachedWalletManager(id: WalletId): WalletManager? { - val manager = walletManager ?: return null - if (manager.id != id) return null + fun cachedWalletManager(id: WalletId): WalletManager? = managerCache.cachedWalletManager(id) - return manager - } - - fun walletMetadata(id: WalletId): WalletMetadata? { - cachedWalletManager(id)?.walletMetadata?.let { return it } - return wallets.firstOrNull { it.id == id } - } + fun walletMetadata(id: WalletId): WalletMetadata? = managerCache.walletMetadata(id, wallets) /** * get or create wallet manager for the given wallet id * caches the instance so we don't recreate unnecessarily */ - fun getWalletManager(id: WalletId): WalletManager { - walletManager?.let { - if (it.id == id) { - Log.d(tag, "found and using wallet manager for $id") - return it - } - - // selecting a different wallet is the boundary for ending in-flight scans - Log.d(tag, "closing old wallet manager for ${it.id}") - clearWalletManager() - } - - Log.d(tag, "did not find wallet manager for $id, creating new: ${walletManager?.id}") - - return try { - val manager = WalletManager(id = id) - walletManager = manager - manager - } catch (e: Exception) { - Log.e(tag, "Failed to create wallet manager", e) - throw e - } - } + fun getWalletManager(id: WalletId): WalletManager = managerCache.getWalletManager(id) /** * get or create send flow manager for the given wallet manager * caches the instance so we don't recreate unnecessarily */ - fun getSendFlowManager(wm: WalletManager, presenter: SendFlowPresenter): SendFlowManager { - sendFlowManager?.let { - if (it.id == wm.id) { - Log.d(tag, "found and using sendflow manager for ${wm.id}") - it.presenter = presenter - return it - } - // close old manager before replacing - Log.d(tag, "closing old sendflow manager for ${it.id}") - clearSendFlowManager() - } + fun getSendFlowManager(wm: WalletManager, presenter: SendFlowPresenter): SendFlowManager = + managerCache.getSendFlowManager(wm, presenter) - Log.d(tag, "did not find SendFlowManager for ${wm.id}, creating new") - val manager = SendFlowManager(wm.newSendFlowManager(wm.balance), presenter) - sendFlowManager = manager - return manager - } + fun setCoinControlManager(manager: CoinControlManager) = managerCache.setCoinControlManager(manager) - fun setCoinControlManager(manager: CoinControlManager) { - coinControlManager = manager - } + fun clearCoinControlManager(manager: CoinControlManager) = managerCache.clearCoinControlManager(manager) - fun clearCoinControlManager(manager: CoinControlManager) { - if (coinControlManager === manager) { - coinControlManager = null - } - } + fun reconcileAfterLabelImport(walletId: WalletId) = managerCache.reconcileAfterLabelImport(walletId) - fun reconcileAfterLabelImport(walletId: WalletId) { - mainScope.launch { - val refreshed = - try { - reconcileAfterLabelImportAndWait(walletId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(tag, "failed to reconcile after label import", e) - false - } - if (!refreshed) { - walletManager - ?.takeIf { it.id == walletId } - ?.notifyLabelRefreshFailed() - } - } - } - - suspend fun reconcileAfterLabelImportAndWait(walletId: WalletId): Boolean { - val refreshed = - walletManager - ?.takeIf { it.id == walletId } - ?.reconcileAfterLabelImportAndWait() - ?: false - - coinControlManager - ?.takeIf { it.id == walletId } - ?.reloadLabels() - - sendFlowManager - ?.takeIf { it.id == walletId } - ?.reconcileAfterLabelImport() - - return refreshed - } - - fun clearWalletManager() { - clearWalletScopedChildManagers() - - try { - walletManager?.close() - } catch (e: Exception) { - Log.w(tag, "Error closing WalletManager: ${e.message}") - } - walletManager = null - } - - private fun clearWalletManager(id: WalletId) { - if (walletManager?.id == id) { - clearWalletManager() - } - - if (sendFlowManager?.id == id) { - clearSendFlowManager() - } - } - - private fun clearWalletScopedChildManagers() { - clearSendFlowManager() - clearActiveCoinControlManager() - } - - private fun clearSendFlowManager() { - try { - sendFlowManager?.close() - } catch (e: Exception) { - Log.w(tag, "Error closing SendFlowManager: ${e.message}") - } - sendFlowManager = null - } + suspend fun reconcileAfterLabelImportAndWait(walletId: WalletId): Boolean = + managerCache.reconcileAfterLabelImportAndWait(walletId) - private fun clearActiveCoinControlManager() { - try { - coinControlManager?.close() - } catch (e: Exception) { - Log.w(tag, "Error closing CoinControlManager: ${e.message}") - } - coinControlManager = null - } + fun clearWalletManager() = managerCache.clearWalletManager() - private fun clearInactiveSendFlowManager() { - val manager = sendFlowManager ?: return - if (routeStackContainsSendWallet(router.default, router.routes, manager.id)) return + private fun clearWalletManager(id: WalletId) = managerCache.clearWalletManager(id) - clearSendFlowManager() - } + private fun clearInactiveSendFlowManager() = managerCache.clearInactiveSendFlowManager(router) val fullVersionId: String get() { @@ -354,13 +268,6 @@ class AppManager private constructor() : FfiReconcile { * clears all cached data and reinitializes */ fun reset() { - pendingSidebarNavigationJob?.cancel() - pendingSidebarNavigationJob = null - navigationSettleJob?.cancel() - navigationSettleJob = null - isNavigationSettled = true - advanceNavigationGeneration(skipSettle = true) - // close managers before clearing them clearWalletManager() @@ -369,23 +276,16 @@ class AppManager private constructor() : FfiReconcile { needsOnboarding() } - withRustOr(null) { + val routerState = withRustOr(null) { state() - }?.let { - router = RouterManager(it.router) } + router.reset(routerState?.router) } val currentRoute: Route get() = router.currentRoute - fun canGoBack(): Boolean = - withRustOr(false) { - canGoBack() - } - - private fun isDuplicateTopRoute(route: Route): Boolean = - currentRoute.isSameNavigationDestination(route) + fun canGoBack(): Boolean = router.canGoBack() val hasWallets: Boolean get() = withRustOr(false) { hasWallets() } @@ -409,7 +309,7 @@ class AppManager private constructor() : FfiReconcile { @Throws(Exception::class) fun selectWalletOrThrow(id: WalletId) { - advanceNavigationGeneration() + router.advanceNavigationGeneration() selectWalletWithoutNavigationGeneration(id) } @@ -429,13 +329,13 @@ class AppManager private constructor() : FfiReconcile { @Throws(Exception::class) fun selectLatestOrNewWallet() { - advanceNavigationGeneration() + router.advanceNavigationGeneration() dispatchResult(AppAction.SelectLatestOrNewWallet).getOrThrow() isSidebarVisible = false } fun toggleSidebar() { - isSidebarVisible = !isSidebarVisible + router.toggleSidebar() } fun loadWallets() { @@ -475,55 +375,18 @@ class AppManager private constructor() : FfiReconcile { } private fun closeSidebarThenNavigate(action: suspend () -> Unit) { - pendingSidebarNavigationJob?.cancel() - val generation = advanceNavigationGeneration() - isSidebarVisible = false - pendingSidebarNavigationJob = mainScope.launch { - kotlinx.coroutines.delay(SIDEBAR_NAVIGATION_DELAY_MS) - if (!isNavigationGenerationCurrent(generation)) return@launch - action() - } + router.closeSidebarAndNavigate(action) } fun pushRoute(route: Route) { - if (isDuplicateTopRoute(route)) { - isSidebarVisible = false - return - } - - if (pushRouteWithoutNavigationGeneration(route)) { - advanceNavigationGeneration() - } + router.pushRoute(route) } - private fun pushRouteWithoutNavigationGeneration(route: Route): Boolean { - Log.d(tag, "pushRoute: $route") - isSidebarVisible = false - if (isDuplicateTopRoute(route)) return false - - val newRoutes = router.routes.toMutableList().apply { add(route) } - if (dispatchRouteUpdate(newRoutes).isFailure) return false - - updateRoutesAndClearInactiveSendFlowManager(newRoutes) - - return true - } + private fun pushRouteWithoutNavigationGeneration(route: Route): Boolean = + router.pushRouteWithoutNavigationGeneration(route) fun pushRoutes(routes: List) { - if (pushRoutesWithoutNavigationGeneration(routes)) { - advanceNavigationGeneration() - } - } - - private fun pushRoutesWithoutNavigationGeneration(routes: List): Boolean { - Log.d(tag, "pushRoutes: ${routes.size} routes") - isSidebarVisible = false - val newRoutes = router.routes.toMutableList().apply { addAll(routes) } - if (dispatchRouteUpdate(newRoutes).isFailure) return false - - updateRoutesAndClearInactiveSendFlowManager(newRoutes) - - return true + router.pushRoutes(routes) } /** @@ -532,59 +395,22 @@ class AppManager private constructor() : FfiReconcile { * @return `true` only when a route was removed */ fun popRoute(): Boolean { - return popRouteForRecovery() == RoutePopResult.Popped + return router.popRoute() } - internal fun popRouteForRecovery(): RoutePopResult { - Log.d(tag, "popRoute") - if (!canGoBack()) { - return RoutePopResult.NoRouteToPop - } - - val currentRoutes = router.routes - if (currentRoutes.isEmpty()) { - return RoutePopResult.NoRouteToPop - } - - val newRoutes = currentRoutes.dropLast(1) - val dispatchError = dispatchRouteUpdate(newRoutes).exceptionOrNull() - if (dispatchError != null) { - return RoutePopResult.Failed(RouteUpdateDispatchException(dispatchError)) - } - - advanceNavigationGeneration() - updateRoutesAndClearInactiveSendFlowManager(newRoutes) - - return RoutePopResult.Popped - } + internal fun popRouteForRecovery(): RoutePopResult = router.popRouteForRecovery() fun setRoute(routes: List) { - Log.d(tag, "setRoute: ${routes.size} routes") - - if (dispatchRouteUpdate(routes).isFailure) return - - advanceNavigationGeneration() - updateRoutesAndClearInactiveSendFlowManager(routes) - } - - private fun dispatchRouteUpdate(routes: List): Result { - if (routes == router.routes) return Result.success(Unit) - - return dispatchResult(AppAction.UpdateRoute(routes)) - } - - private fun updateRoutesAndClearInactiveSendFlowManager(routes: List) { - router.updateRoutes(routes) - clearInactiveSendFlowManager() + router.setRoute(routes) } fun scanQr() { - advanceNavigationGeneration() + router.advanceNavigationGeneration() sheetState = TaggedItem(AppSheetState.Qr) } fun scanNfc() { - advanceNavigationGeneration() + router.advanceNavigationGeneration() scanNfcWithoutNavigationGeneration() } @@ -593,65 +419,37 @@ class AppManager private constructor() : FfiReconcile { } fun resetRoute(to: List) { - advanceNavigationGeneration() - resetRouteWithoutNavigationGeneration(to) + router.resetRoute(to) } - private fun resetRouteWithoutNavigationGeneration(to: List) { - if (to.size > 1) { - withRustOr(Unit) { - resetNestedRoutesTo(to[0], to.drop(1)) - } - } else if (to.isNotEmpty()) { - withRustOr(Unit) { - resetDefaultRouteTo(to[0]) - } - } - } + private fun resetRouteWithoutNavigationGeneration(to: List) = + router.resetRouteWithoutNavigationGeneration(to) fun resetRoute(to: Route) { - advanceNavigationGeneration() - resetRouteWithoutNavigationGeneration(to) + router.resetRoute(to) } - private fun resetRouteWithoutNavigationGeneration(to: Route) { - withRustOr(Unit) { - resetDefaultRouteTo(to) - } - } + private fun resetRouteWithoutNavigationGeneration(to: Route) = + router.resetRouteWithoutNavigationGeneration(to) fun loadAndReset(to: Route) { - advanceNavigationGeneration() - withRustOr(Unit) { - loadAndResetDefaultRoute(to) - } + router.loadAndReset(to) } - fun captureLoadAndResetGeneration(): GenerationToken = navigationGenerations.capture() + fun captureLoadAndResetGeneration(): GenerationToken = router.captureLoadAndResetGeneration() fun startLoadAndResetTargetPrewarm( generation: GenerationToken, nextRoutes: List, ) { - mainScope.launch { - prewarmLoadAndResetTargetIfCurrent(generation, nextRoutes) - } + router.startLoadAndResetTargetPrewarm(generation, nextRoutes) } suspend fun prewarmLoadAndResetTargetIfCurrent( generation: GenerationToken, nextRoutes: List, ) { - if (!isNavigationGenerationCurrent(generation)) return - val selectedWalletRoute = nextRoutes.firstOrNull() as? Route.SelectedWallet ?: return - - try { - getWalletManager(selectedWalletRoute.v1).startWalletScanIfNeeded() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(tag, "Unable to prewarm selected wallet ${selectedWalletRoute.v1}", e) - } + router.prewarmLoadAndResetTargetIfCurrent(generation, nextRoutes) } fun resetAfterLoadingIfCurrent( @@ -659,41 +457,9 @@ class AppManager private constructor() : FfiReconcile { route: Route.LoadAndReset, nextRoutes: List, ) { - if (!isNavigationGenerationCurrent(generation)) return - if (router.default != route) return - withRustOr(Unit) { - resetAfterLoading(nextRoutes) - } + router.resetAfterLoadingIfCurrent(generation, route, nextRoutes) } - private fun advanceNavigationGeneration(skipSettle: Boolean = false): GenerationToken { - val generation = navigationGenerations.advance() - if (!skipSettle) { - scheduleNavigationSettled(generation) - } - return generation - } - - private fun scheduleNavigationSettledForCurrentGeneration() { - scheduleNavigationSettled(navigationGenerations.capture()) - } - - private fun scheduleNavigationSettled(generation: GenerationToken) { - navigationSettleJob?.cancel() - isNavigationSettled = false - - navigationSettleJob = - mainScope.launch { - kotlinx.coroutines.delay(NAVIGATION_SETTLE_DELAY_MS) - if (!isNavigationGenerationCurrent(generation)) return@launch - isNavigationSettled = true - navigationSettleJob = null - } - } - - private fun isNavigationGenerationCurrent(generation: GenerationToken): Boolean = - navigationGenerations.isCurrent(generation) - suspend fun initData() { withRustSuspend { initData() @@ -722,22 +488,11 @@ class AppManager private constructor() : FfiReconcile { mainScope.launch { when (message) { is AppStateReconcileMessage.RouteUpdated -> { - val didChangeRoute = router.routes != message.v1.toList() - updateRoutesAndClearInactiveSendFlowManager(message.v1.toList()) - if (didChangeRoute) { - scheduleNavigationSettledForCurrentGeneration() - } + router.reconcileRouteUpdated(message.v1.toList()) } is AppStateReconcileMessage.PushedRoute -> { - if (isDuplicateTopRoute(message.v1)) { - isSidebarVisible = false - return@launch - } - - val newRoutes = (router.routes + message.v1).toList() - updateRoutesAndClearInactiveSendFlowManager(newRoutes) - scheduleNavigationSettledForCurrentGeneration() + router.reconcilePushedRoute(message.v1) } is AppStateReconcileMessage.DatabaseUpdated -> { @@ -759,11 +514,7 @@ class AppManager private constructor() : FfiReconcile { } is AppStateReconcileMessage.DefaultRouteChanged -> { - router.default = message.v1 - updateRoutesAndClearInactiveSendFlowManager(message.v2.toList()) - routeId = UUID.randomUUID().toString() - scheduleNavigationSettledForCurrentGeneration() - Log.d(tag, "Route ID changed to: $routeId") + router.reconcileDefaultRouteChanged(message.v1, message.v2.toList()) } is AppStateReconcileMessage.FiatPricesChanged -> { @@ -776,14 +527,7 @@ class AppManager private constructor() : FfiReconcile { is AppStateReconcileMessage.FiatCurrencyChanged -> { selectedFiatCurrency = message.v1 - - // refresh fiat values in the wallet manager using IO - walletManager?.let { wm -> - launch(Dispatchers.IO) { - wm.forceWalletScan() - wm.updateWalletBalance() - } - } + managerCache.refreshFiatValuesForCachedWallet(this) } is AppStateReconcileMessage.WalletModeChanged -> { @@ -834,15 +578,6 @@ class AppManager private constructor() : FfiReconcile { @Volatile private var instance: AppManager? = null - /** - * delay after closing sidebar before navigation action executes - * - * allows sidebar dismiss animation to complete to avoid visual jump - */ - private const val SIDEBAR_NAVIGATION_DELAY_MS = 250L - - private const val NAVIGATION_SETTLE_DELAY_MS = 800L - /** * minimum loading indicator visibility duration * diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt index 8c42b6c79..07bfae57c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt @@ -1,23 +1,15 @@ package org.bitcoinppl.cove import androidx.compose.runtime.Stable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* import java.io.Closeable import java.util.concurrent.atomic.AtomicBoolean @Stable -class ImportWalletManager : - ImportWalletManagerReconciler, - Closeable { +class ImportWalletManager : Closeable { private val tag = "ImportWalletManager" private val isClosed = AtomicBoolean(false) - private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val rustGuard = RustHandleGuard( ownerName = "ImportWalletManager", @@ -32,36 +24,12 @@ class ImportWalletManager : init { Log.d(tag, "Initializing ImportWalletManager") rust = RustImportWalletManager() - rust.listenForUpdates(this) } private fun withRust( block: RustImportWalletManager.() -> T, ): T = rustGuard.withHandle(rust, block) - private fun withRustOr( - defaultValue: T, - block: RustImportWalletManager.() -> T, - ): T = rustGuard.withHandleOr(rust, defaultValue, block) - - override fun reconcile(message: ImportWalletManagerReconcileMessage) { - Log.d(tag, "Reconcile: $message") - mainScope.launch { - when (message) { - ImportWalletManagerReconcileMessage.NO_OP -> { - // no-op - } - } - } - } - - fun dispatch(action: ImportWalletManagerAction) { - Log.d(tag, "Dispatch: $action") - withRustOr(Unit) { - dispatch(action) - } - } - /** * Import wallet from entered words * @param enteredWords List of lists of words (for paginated input) @@ -79,7 +47,6 @@ class ImportWalletManager : override fun close() { rustGuard.closeOnce { Log.d(tag, "Closing ImportWalletManager") - mainScope.cancel() rust.close() } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/Log.kt b/android/app/src/main/java/org/bitcoinppl/cove/Log.kt index e21517cdb..6322c31b7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/Log.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/Log.kt @@ -1,6 +1,7 @@ package org.bitcoinppl.cove import android.util.Log as AndroidLog +import kotlin.coroutines.cancellation.CancellationException object Log { fun d(tag: String, message: String) { @@ -35,3 +36,18 @@ object Log { AndroidLog.i(tag, message) } } + +@Suppress("TooGenericExceptionCaught") +inline fun runCatchingCancellable( + tag: String, + message: String, + block: () -> T, +): Result = + try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(tag, message, e) + Result.failure(e) + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/RouterManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/RouterManager.kt index 1c3456da2..64c7895de 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/RouterManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/RouterManager.kt @@ -1,19 +1,55 @@ package org.bitcoinppl.cove +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.structuralEqualityPolicy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* +import org.bitcoinppl.cove_core.util.GenerationToken +import org.bitcoinppl.cove_core.util.GenerationTracker +import java.util.UUID + +private class RouteUpdateDispatchException(cause: Throwable) : Exception("Unable to dispatch route update", cause) + +internal interface RouterManagerHost { + fun canPopRoute(): Boolean + + fun dispatchRouteUpdate(routes: List): Result + + fun resetDefaultRouteTo(route: Route) + + fun resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: List) + + fun loadAndResetDefaultRoute(route: Route) + + fun resetAfterLoading(routes: List) + + fun onRoutesChanged() + + suspend fun startWalletScanIfNeeded(walletId: WalletId): Result +} /** * wrapper around FFI Router to make it observable in Compose * manages navigation state for the app */ -class RouterManager( +@Suppress("TooManyFunctions") +@Stable +class RouterManager internal constructor( internal var ffiRouter: Router, + private val mainScope: CoroutineScope, + private val host: RouterManagerHost, ) { + private val tag = "RouterManager" + private val navigationGenerations = GenerationTracker() + private var pendingSidebarNavigationJob: Job? = null + private var navigationSettleJob: Job? = null + // observable properties for Compose with structural equality to prevent feedback loops var default: Route by mutableStateOf(ffiRouter.default, structuralEqualityPolicy()) internal set @@ -21,6 +57,16 @@ class RouterManager( var routes: List by mutableStateOf(ffiRouter.routes, structuralEqualityPolicy()) internal set + var isSidebarVisible by mutableStateOf(false) + internal set + + var isNavigationSettled by mutableStateOf(true) + private set + + // route id changes when route is reset, to clear lifecycle view state + var routeId by mutableStateOf(UUID.randomUUID().toString()) + private set + /** * current/top route in the stack, falling back to default if stack is empty */ @@ -36,11 +82,264 @@ class RouterManager( this.routes = ffiRouter.routes } + fun reset(ffiRouter: Router?) { + pendingSidebarNavigationJob?.cancel() + pendingSidebarNavigationJob = null + navigationSettleJob?.cancel() + navigationSettleJob = null + isNavigationSettled = true + advanceNavigationGeneration(skipSettle = true) + + ffiRouter?.let { syncFromFfi(it) } + } + + fun canGoBack(): Boolean = host.canPopRoute() + + fun closeSidebarAndNavigate(action: suspend () -> Unit) { + pendingSidebarNavigationJob?.cancel() + val generation = advanceNavigationGeneration() + isSidebarVisible = false + pendingSidebarNavigationJob = + mainScope.launch { + kotlinx.coroutines.delay(SIDEBAR_NAVIGATION_DELAY_MS) + if (!isNavigationGenerationCurrent(generation)) return@launch + action() + } + } + + fun toggleSidebar() { + isSidebarVisible = !isSidebarVisible + } + + fun pushRoute(route: Route) { + if (isDuplicateTopRoute(route)) { + isSidebarVisible = false + return + } + + if (pushRouteWithoutNavigationGeneration(route)) { + advanceNavigationGeneration() + } + } + + internal fun pushRouteWithoutNavigationGeneration(route: Route): Boolean { + Log.d(tag, "pushRoute: $route") + isSidebarVisible = false + var didPush = false + + if (!isDuplicateTopRoute(route)) { + val newRoutes = routes.toMutableList().apply { add(route) } + if (dispatchRouteUpdate(newRoutes).isSuccess) { + updateRoutes(newRoutes) + didPush = true + } + } + + return didPush + } + + fun pushRoutes(routes: List) { + if (pushRoutesWithoutNavigationGeneration(routes)) { + advanceNavigationGeneration() + } + } + + private fun pushRoutesWithoutNavigationGeneration(routes: List): Boolean { + Log.d(tag, "pushRoutes: ${routes.size} routes") + isSidebarVisible = false + val newRoutes = this.routes.toMutableList().apply { addAll(routes) } + if (dispatchRouteUpdate(newRoutes).isFailure) return false + + updateRoutes(newRoutes) + + return true + } + /** - * update routes list (called by AppManager when manually pushing/popping) + * Pops the top route when both Rust and the local route stack can go back + * + * @return `true` only when a route was removed */ - internal fun updateRoutes(newRoutes: List) { + fun popRoute(): Boolean { + return popRouteForRecovery() == RoutePopResult.Popped + } + + internal fun popRouteForRecovery(): RoutePopResult { + Log.d(tag, "popRoute") + val result = + if (!canGoBack() || routes.isEmpty()) { + RoutePopResult.NoRouteToPop + } else { + popAvailableRoute() + } + + return result + } + + fun setRoute(routes: List) { + Log.d(tag, "setRoute: ${routes.size} routes") + + if (dispatchRouteUpdate(routes).isFailure) return + + advanceNavigationGeneration() + updateRoutes(routes) + } + + fun resetRoute(to: List) { + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to) + } + + internal fun resetRouteWithoutNavigationGeneration(to: List) { + if (to.size > 1) { + host.resetNestedRoutesTo(to[0], to.drop(1)) + } else if (to.isNotEmpty()) { + host.resetDefaultRouteTo(to[0]) + } + } + + fun resetRoute(to: Route) { + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to) + } + + internal fun resetRouteWithoutNavigationGeneration(to: Route) { + host.resetDefaultRouteTo(to) + } + + fun loadAndReset(to: Route) { + advanceNavigationGeneration() + host.loadAndResetDefaultRoute(to) + } + + fun captureLoadAndResetGeneration(): GenerationToken = navigationGenerations.capture() + + fun startLoadAndResetTargetPrewarm( + generation: GenerationToken, + nextRoutes: List, + ) { + mainScope.launch { + prewarmLoadAndResetTargetIfCurrent(generation, nextRoutes) + } + } + + suspend fun prewarmLoadAndResetTargetIfCurrent( + generation: GenerationToken, + nextRoutes: List, + ) { + if (!isNavigationGenerationCurrent(generation)) return + val selectedWalletRoute = nextRoutes.firstOrNull() as? Route.SelectedWallet ?: return + + host.startWalletScanIfNeeded(selectedWalletRoute.v1) + .onFailure { e -> + Log.e(tag, "Unable to prewarm selected wallet ${selectedWalletRoute.v1}", e) + } + } + + fun resetAfterLoadingIfCurrent( + generation: GenerationToken, + route: Route.LoadAndReset, + nextRoutes: List, + ) { + if (!isNavigationGenerationCurrent(generation)) return + if (default != route) return + host.resetAfterLoading(nextRoutes) + } + + internal fun reconcileRouteUpdated(routes: List) { + val didChangeRoute = this.routes != routes + updateRoutes(routes) + if (didChangeRoute) { + scheduleNavigationSettledForCurrentGeneration() + } + } + + internal fun reconcilePushedRoute(route: Route) { + if (isDuplicateTopRoute(route)) { + isSidebarVisible = false + return + } + + val newRoutes = (routes + route).toList() + updateRoutes(newRoutes) + scheduleNavigationSettledForCurrentGeneration() + } + + internal fun reconcileDefaultRouteChanged( + default: Route, + routes: List, + ) { + this.default = default + updateRoutes(routes) + routeId = UUID.randomUUID().toString() + scheduleNavigationSettledForCurrentGeneration() + Log.d(tag, "Route ID changed to: $routeId") + } + + internal fun advanceNavigationGeneration(skipSettle: Boolean = false): GenerationToken { + val generation = navigationGenerations.advance() + if (!skipSettle) { + scheduleNavigationSettled(generation) + } + return generation + } + + private fun dispatchRouteUpdate(routes: List): Result { + if (routes == this.routes) return Result.success(Unit) + + return host.dispatchRouteUpdate(routes) + } + + private fun popAvailableRoute(): RoutePopResult { + val newRoutes = routes.dropLast(1) + val dispatchError = dispatchRouteUpdate(newRoutes).exceptionOrNull() + if (dispatchError != null) { + return RoutePopResult.Failed(RouteUpdateDispatchException(dispatchError)) + } + + advanceNavigationGeneration() + updateRoutes(newRoutes) + + return RoutePopResult.Popped + } + + private fun updateRoutes(newRoutes: List) { routes = newRoutes + host.onRoutesChanged() + } + + private fun isDuplicateTopRoute(route: Route): Boolean = + currentRoute.isSameNavigationDestination(route) + + private fun scheduleNavigationSettledForCurrentGeneration() { + scheduleNavigationSettled(navigationGenerations.capture()) + } + + private fun scheduleNavigationSettled(generation: GenerationToken) { + navigationSettleJob?.cancel() + isNavigationSettled = false + + navigationSettleJob = + mainScope.launch { + kotlinx.coroutines.delay(NAVIGATION_SETTLE_DELAY_MS) + if (!isNavigationGenerationCurrent(generation)) return@launch + isNavigationSettled = true + navigationSettleJob = null + } + } + + private fun isNavigationGenerationCurrent(generation: GenerationToken): Boolean = + navigationGenerations.isCurrent(generation) + + private companion object { + /** + * delay after closing sidebar before navigation action executes + * + * allows sidebar dismiss animation to complete to avoid visual jump + */ + private const val SIDEBAR_NAVIGATION_DELAY_MS = 250L + + private const val NAVIGATION_SETTLE_DELAY_MS = 800L } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt index a6ca2df4d..8057327ae 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -443,6 +442,11 @@ class WalletManager : numberOfConfirmations(blockHeight) } + fun displayConfirmationCount(confirmations: UInt): String = + withRustOr("") { + displayConfirmationCount(confirmations) + } + fun amountFmt(amount: Amount): String = when (walletMetadata?.selectedUnit) { BitcoinUnit.BTC -> amount.btcString() @@ -578,38 +582,31 @@ class WalletManager : var refreshedDetails = true for (txId in cachedTransactionIds) { - try { - refreshTransactionDetails(txId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(tag, "failed to refresh transaction details after label import", e) + val refreshed = + runCatchingCancellable(tag, "failed to refresh transaction details after label import") { + refreshTransactionDetails(txId) + }.isSuccess + if (!refreshed) { refreshedDetails = false } } for (txId in cachedLockStateTransactionIds) { - try { - transactionLockState(txId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(tag, "failed to refresh transaction lock state after label import", e) + val refreshed = + runCatchingCancellable(tag, "failed to refresh transaction lock state after label import") { + transactionLockState(txId) + }.isSuccess + if (!refreshed) { clearTransactionLockState(txId) } } - return try { + return runCatchingCancellable(tag, "failed to refresh transactions after label import") { withRustSuspend { getTransactions() } refreshedDetails - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(tag, "failed to refresh transactions after label import", e) - false - } + }.getOrDefault(false) } fun notifyLabelRefreshFailed() { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccess.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccess.kt index 94eaf978e..4ca5778de 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccess.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccess.kt @@ -1,36 +1,19 @@ package org.bitcoinppl.cove.cloudbackup import android.content.Context -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.common.api.CommonStatusCodes -import java.io.ByteArrayOutputStream -import java.io.IOException import java.net.HttpURLConnection -import java.net.SocketTimeoutException -import java.net.URLEncoder -import java.net.URL -import java.net.UnknownHostException -import java.nio.charset.StandardCharsets -import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.bitcoinppl.cove.Log import org.bitcoinppl.cove_core.device.CloudAccessPolicy import org.bitcoinppl.cove_core.device.CloudStorageAccess import org.bitcoinppl.cove_core.device.CloudStorageException import org.bitcoinppl.cove_core.device.CloudSyncHealth import org.bitcoinppl.cove_core.device.RemoteBackupLocation import org.bitcoinppl.cove_core.device.cloudBackupLocationsSyncHealth -import org.json.JSONArray -import org.json.JSONObject class AndroidCloudStorageAccess internal constructor( - private val driveAuthorization: DriveAuthorization, - private val accountBindingStore: DriveAccountBindingStore, - private val driveApiEndpoints: DriveApiEndpoints = DriveApiEndpoints(), + driveAuthorization: DriveAuthorization, + accountBindingStore: DriveAccountBindingStore, + driveApiEndpoints: DriveApiEndpoints = DriveApiEndpoints(), drivePathNamesProvider: () -> DrivePathNames = { DrivePaths.defaultNames }, ) : CloudStorageAccess { constructor(context: Context) : this( @@ -47,10 +30,9 @@ class AndroidCloudStorageAccess internal constructor( accountBindingStore, ) - private val namespacesRootFolderMutex = Mutex() - private val namespaceFolderMutexes = ConcurrentHashMap() - private val childFolderMutexes = ConcurrentHashMap() - private val drivePathNames: DrivePathNames by lazy(drivePathNamesProvider) + private val httpClient = DriveHttpClient(driveApiEndpoints) + private val folderResolver = DriveFolderResolver(httpClient, drivePathNamesProvider) + private val tokenProvider = DriveTokenProvider(driveAuthorization, accountBindingStore, httpClient) private fun CloudAccessPolicy.allowsConsent(): Boolean = this == CloudAccessPolicy.CONSENT_ALLOWED @@ -61,16 +43,16 @@ class AndroidCloudStorageAccess internal constructor( data: ByteArray, policy: CloudAccessPolicy, ) { - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> mapDriveUploadError(error, location.errorId("master key backup")) }, bindAccountOnSuccess = { true }, ) { token -> - val namespaceFolderId = ensureNamespaceFolderId(token, namespace) - val parentId = ensureLocationParentFolderId(token, namespaceFolderId, location) - upsertFile( + val namespaceFolderId = folderResolver.ensureNamespaceFolderId(token, namespace) + val parentId = folderResolver.ensureLocationParentFolderId(token, namespaceFolderId, location) + folderResolver.upsertFile( token = token, parentId = parentId, fileName = location.fileName, @@ -86,14 +68,14 @@ class AndroidCloudStorageAccess internal constructor( data: ByteArray, policy: CloudAccessPolicy, ) { - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> mapDriveUploadError(error, location.errorId(recordId)) }, bindAccountOnSuccess = { true }, ) { token -> - val namespaceFolderId = ensureNamespaceFolderId(token, namespace) - val parentId = ensureLocationParentFolderId(token, namespaceFolderId, location) - upsertFile( + val namespaceFolderId = folderResolver.ensureNamespaceFolderId(token, namespace) + val parentId = folderResolver.ensureLocationParentFolderId(token, namespaceFolderId, location) + folderResolver.upsertFile( token = token, parentId = parentId, fileName = location.fileName, @@ -107,21 +89,21 @@ class AndroidCloudStorageAccess internal constructor( locations: List, policy: CloudAccessPolicy, ): ByteArray = - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> val errorId = locations.firstOrNull()?.errorId("master key backup") ?: "master key backup" mapDriveDownloadError(error, errorId) }, ) { token -> - val namespaceFolderId = requireNamespaceFolderId(token, namespace) + val namespaceFolderId = folderResolver.requireNamespaceFolderId(token, namespace) val fileId = - findFileAtLocations( + folderResolver.findFileAtLocations( token = token, namespaceFolderId = namespaceFolderId, locations = locations, )?.id ?: throw DriveHttpException(404, "master key backup not found") - downloadFile(token, fileId) + httpClient.downloadFile(token, fileId) } override suspend fun downloadWalletBackup( @@ -130,7 +112,7 @@ class AndroidCloudStorageAccess internal constructor( locations: List, policy: CloudAccessPolicy, ): ByteArray = - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> val errorId = locations.firstOrNull()?.errorId(recordId) ?: recordId @@ -138,14 +120,14 @@ class AndroidCloudStorageAccess internal constructor( }, bindAccountOnSuccess = { true }, ) { token -> - val namespaceFolderId = requireNamespaceFolderId(token, namespace) + val namespaceFolderId = folderResolver.requireNamespaceFolderId(token, namespace) val fileId = - findFileAtLocations( + folderResolver.findFileAtLocations( token = token, namespaceFolderId = namespaceFolderId, locations = locations, )?.id ?: throw DriveHttpException(404, "wallet backup not found") - downloadFile(token, fileId) + httpClient.downloadFile(token, fileId) } override suspend fun deleteWalletBackup( @@ -154,16 +136,16 @@ class AndroidCloudStorageAccess internal constructor( locations: List, policy: CloudAccessPolicy, ) { - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> val errorId = locations.firstOrNull()?.errorId(recordId) ?: recordId mapDriveDeleteError(error, errorId) }, ) { token -> - val namespaceFolderId = requireNamespaceFolderId(token, namespace) + val namespaceFolderId = folderResolver.requireNamespaceFolderId(token, namespace) val files = - findFilesAtLocations( + folderResolver.findFilesAtLocations( token = token, namespaceFolderId = namespaceFolderId, locations = locations, @@ -176,10 +158,10 @@ class AndroidCloudStorageAccess internal constructor( val failures = mutableListOf() files.forEach { file -> try { - driveRequest( + httpClient.driveRequest( token = token, method = "DELETE", - url = "${driveApiEndpoints.filesEndpoint}/${file.id}", + url = "${httpClient.endpoints.filesEndpoint}/${file.id}", ) } catch (error: Throwable) { if (error is CancellationException) throw error @@ -188,6 +170,7 @@ class AndroidCloudStorageAccess internal constructor( } } + // report partial failures after best-effort cleanup so callers can retry remaining files if (failures.isNotEmpty()) { throw aggregateDeleteFailures(failures) } @@ -198,22 +181,11 @@ class AndroidCloudStorageAccess internal constructor( namespace: String, policy: CloudAccessPolicy, ) { - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> mapDriveDeleteError(error, namespace) }, ) { token -> - val mutex = namespaceFolderMutexes.computeIfAbsent(namespace) { Mutex() } - mutex.withLock { - val namespaceFolderId = - findNamespaceFolderId(token, namespace) - ?: throw DriveHttpException(404, "namespace not found") - - driveRequest( - token = token, - method = "DELETE", - url = "${driveApiEndpoints.filesEndpoint}/$namespaceFolderId", - ) - } + folderResolver.deleteNamespace(token, namespace) } } @@ -232,22 +204,23 @@ class AndroidCloudStorageAccess internal constructor( locations: List, policy: CloudAccessPolicy, ): Boolean = - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> mapDriveListError(error) }, bindAccountOnSuccess = { uploaded -> uploaded }, ) { token -> - val namespaceFolderId = findNamespaceFolderId(token, namespace) ?: return@runDriveOperation false - findFileAtLocations(token, namespaceFolderId, locations) != null + val namespaceFolderId = folderResolver.findNamespaceFolderId(token, namespace) + ?: return@runDriveOperation false + folderResolver.findFileAtLocations(token, namespaceFolderId, locations) != null } override suspend fun overallSyncHealth(policy: CloudAccessPolicy): CloudSyncHealth = try { - runDriveOperation( + tokenProvider.runDriveOperation( interactive = policy.allowsConsent(), onError = { error -> throw error }, ) { token -> - cloudBackupLocationsSyncHealth(listNamespaceBackupLocations(token)) + cloudBackupLocationsSyncHealth(folderResolver.listNamespaceBackupLocations(token)) } } catch (error: Throwable) { if (error is CancellationException) throw error @@ -261,729 +234,28 @@ class AndroidCloudStorageAccess internal constructor( } } - private suspend fun findNamespacesRootFolderId(token: String): String? = - findChildByName( - token = token, - parentId = APP_DATA_FOLDER, - fileName = drivePathNames.namespacesRootFolderName, - foldersOnly = true, - )?.id - - private suspend fun ensureNamespacesRootFolderId(token: String): String = - namespacesRootFolderMutex.withLock { - findNamespacesRootFolderId(token) - ?: run { - val createdId = - createFolder( - token = token, - parentId = APP_DATA_FOLDER, - folderName = drivePathNames.namespacesRootFolderName, - ) - findNamespacesRootFolderId(token) ?: createdId - } - } - - private suspend fun ensureNamespaceFolderId( - token: String, - namespace: String, - ): String { - val rootId = ensureNamespacesRootFolderId(token) - val mutex = namespaceFolderMutexes.computeIfAbsent(namespace) { Mutex() } - return mutex.withLock { - findChildByName( - token = token, - parentId = rootId, - fileName = namespace, - foldersOnly = true, - )?.id ?: run { - val createdId = createFolder(token, rootId, namespace) - findChildByName( - token = token, - parentId = rootId, - fileName = namespace, - foldersOnly = true, - )?.id ?: createdId - } - } - } - - private suspend fun findNamespaceFolderId( - token: String, - namespace: String, - ): String? { - val rootId = findNamespacesRootFolderId(token) ?: return null - return findChildByName( - token = token, - parentId = rootId, - fileName = namespace, - foldersOnly = true, - )?.id - } - - private suspend fun requireNamespaceFolderId( - token: String, - namespace: String, - ): String { - val rootId = - findNamespacesRootFolderId(token) - ?: throw DriveHttpException(404, "namespaces root not found") - return findChildByName( - token = token, - parentId = rootId, - fileName = namespace, - foldersOnly = true, - )?.id ?: throw DriveHttpException(404, "namespace not found") - } - - private suspend fun ensureChildFolderId( - token: String, - parentId: String, - folderName: String, - ): String { - val mutex = childFolderMutexes.computeIfAbsent("$parentId/$folderName") { Mutex() } - return mutex.withLock { - findChildByName( - token = token, - parentId = parentId, - fileName = folderName, - foldersOnly = true, - )?.id ?: run { - val createdId = createFolder(token, parentId, folderName) - findChildByName( - token = token, - parentId = parentId, - fileName = folderName, - foldersOnly = true, - )?.id ?: createdId - } - } - } - - private suspend fun ensureLocationParentFolderId( - token: String, - namespaceFolderId: String, - location: RemoteBackupLocation, - ): String { - var parentId = namespaceFolderId - for (folderName in location.parts.parentFolders) { - parentId = - ensureChildFolderId( - token = token, - parentId = parentId, - folderName = folderName, - ) - } - - return parentId - } - - private suspend fun findFilesAtLocations( - token: String, - namespaceFolderId: String, - locations: List, - ): List = - locations - .mapNotNull { location -> - findFileAtLocation( - token = token, - namespaceFolderId = namespaceFolderId, - location = location, - ) - } - .distinctBy { it.id } - - private suspend fun findFileAtLocations( - token: String, - namespaceFolderId: String, - locations: List, - ): DriveFileMetadata? = - locations.firstNotNullOfOrNull { location -> - findFileAtLocation( - token = token, - namespaceFolderId = namespaceFolderId, - location = location, - ) - } - - private suspend fun findFileAtLocation( - token: String, - namespaceFolderId: String, - location: RemoteBackupLocation, - ): DriveFileMetadata? { - val parts = location.parts - var parentId = namespaceFolderId - for (folderName in parts.parentFolders) { - parentId = - findChildByName( - token = token, - parentId = parentId, - fileName = folderName, - foldersOnly = true, - )?.id ?: return null - } - - return findChildByName( - token = token, - parentId = parentId, - fileName = parts.fileName, - ) - } - - private suspend fun createFolder( - token: String, - parentId: String, - folderName: String, - ): String { - val metadata = - JSONObject() - .put("name", folderName) - .put("mimeType", DriveApi.FOLDER_MIME_TYPE) - .put("parents", JSONArray().put(parentId)) - - val response = - driveRequest( - token = token, - method = "POST", - url = driveApiEndpoints.filesEndpoint, - body = metadata.toString().toByteArray(), - contentType = "application/json; charset=utf-8", - ).asJsonObject() - - return response.getString("id") - } - - private suspend fun upsertFile( - token: String, - parentId: String, - fileName: String, - data: ByteArray, - ) { - val existing = - findChildByName( - token = token, - parentId = parentId, - fileName = fileName, - ) - - val metadata = - if (existing == null) { - createUploadMetadata(fileName, parentId).toJson() - } else { - overwriteUploadMetadata(fileName).toJson() - } - - val boundary = "cove-${System.currentTimeMillis()}" - val body = buildMultipartBody(boundary, metadata, data) - val url = - if (existing == null) { - "${driveApiEndpoints.uploadEndpoint}?uploadType=multipart" - } else { - "${driveApiEndpoints.uploadEndpoint}/${existing.id}?uploadType=multipart" - } - - driveRequest( - token = token, - method = if (existing == null) "POST" else "PATCH", - url = url, - body = body, - contentType = "multipart/related; boundary=$boundary", - ) - } - private suspend fun listWalletFiles( namespace: String, interactive: Boolean, ): List = - runDriveOperation( + tokenProvider.runDriveOperation( interactive = interactive, onError = { error -> mapDriveListError(error) }, bindAccountOnSuccess = { true }, ) { token -> - val namespaceFolderId = requireNamespaceFolderId(token, namespace) - listBackupLocations( - token = token, - namespaceFolderId = namespaceFolderId, - ).filter(drivePathNames::isWalletFile) - } - - private suspend fun listBackupLocations( - token: String, - namespaceFolderId: String, - ): List { - val immediateChildren = - listChildren( - token = token, - parentId = namespaceFolderId, - foldersOnly = false, - ) - - val locations = - immediateChildren - .backupFileLocations { it } - .toMutableList() - - immediateChildren - .singleFolderChild(drivePathNames.masterKeyFolderName) - ?.let { masterKeyFolder -> - listChildren( - token = token, - parentId = masterKeyFolder.id, - foldersOnly = false, - ).backupFileLocations { "${drivePathNames.masterKeyFolderName}/$it" } - .let(locations::addAll) - } - - immediateChildren - .singleFolderChild(drivePathNames.walletsFolderName) - ?.let { walletsFolder -> - listChildren( - token = token, - parentId = walletsFolder.id, - foldersOnly = false, - ).backupFileLocations(drivePathNames::walletLocationForFileName) - .let(locations::addAll) - } - - return locations - } - - private suspend fun listNamespaceBackupLocations(token: String): List> { - val namespacesRootId = findNamespacesRootFolderId(token) ?: return emptyList() - - return listNamespaceFolders(token, namespacesRootId) - .filter { namespace -> isValidCloudBackupNamespaceId(namespace.name) } - .map { namespace -> - listBackupLocations( - token = token, - namespaceFolderId = namespace.id, - ) - } - } - - private suspend fun listNamespaceFolders( - token: String, - namespacesRootId: String, - ): List { - val namespaces = - listChildren( - token = token, - parentId = namespacesRootId, - foldersOnly = true, - ) - val duplicates = duplicateDriveFolderNames(namespaces.map { it.name }) - if (duplicates.isNotEmpty()) { - throw duplicateDriveFolderException("namespace") + folderResolver.listWalletFiles(token, namespace) } - return namespaces - } - private suspend fun listNamespaces( interactive: Boolean, ): List = - runDriveOperation( + tokenProvider.runDriveOperation( interactive = interactive, onError = { error -> mapDriveListError(error) }, ) { token -> - val namespacesRootId = findNamespacesRootFolderId(token) ?: return@runDriveOperation emptyList() - listNamespaceFolders(token, namespacesRootId).map { it.name } - } - - private fun buildMultipartBody( - boundary: String, - metadata: JSONObject, - data: ByteArray, - ): ByteArray { - val output = ByteArrayOutputStream() - val prefix = "--$boundary\r\n" - output.write(prefix.toByteArray()) - output.write("Content-Type: application/json; charset=UTF-8\r\n\r\n".toByteArray()) - output.write(metadata.toString().toByteArray()) - output.write("\r\n--$boundary\r\n".toByteArray()) - output.write("Content-Type: application/octet-stream\r\n\r\n".toByteArray()) - output.write(data) - output.write("\r\n--$boundary--\r\n".toByteArray()) - return output.toByteArray() - } - - private suspend fun downloadFile( - token: String, - fileId: String, - ): ByteArray = - driveRequest( - token = token, - method = "GET", - url = "${driveApiEndpoints.filesEndpoint}/$fileId?alt=media", - ).body - - private suspend fun listChildren( - token: String, - parentId: String, - foldersOnly: Boolean, - ): List { - val query = - buildString { - append("'") - append(parentId) - append("' in parents and trashed = false") - if (foldersOnly) { - append(" and mimeType = '") - append(DriveApi.FOLDER_MIME_TYPE) - append("'") - } - } - - val children = mutableListOf() - var pageToken: String? = null - - do { - val parameters = - buildList { - add("spaces" to APP_DATA_SPACE) - add("fields" to "nextPageToken,files(id,name,mimeType)") - add("pageSize" to "1000") - add("q" to query) - pageToken?.let { add("pageToken" to it) } - } - - val response = - driveRequest( - token = token, - method = "GET", - url = driveApiUrl(driveApiEndpoints.filesEndpoint, parameters), - ).asJsonObject() - - val files = response.optJSONArray("files") ?: JSONArray() - for (index in 0 until files.length()) { - val file = files.getJSONObject(index) - children.add( - DriveFileMetadata( - id = file.getString("id"), - name = file.getString("name"), - mimeType = file.optString("mimeType"), - ), - ) - } - - pageToken = response.optString("nextPageToken").takeIf(String::isNotBlank) - } while (pageToken != null) - - return children - } - - private suspend fun findChildByName( - token: String, - parentId: String, - fileName: String, - foldersOnly: Boolean = false, - ): DriveFileMetadata? { - val matches = - listChildren(token, parentId, foldersOnly) - .filter { it.name == fileName && (foldersOnly || !it.isFolder) } - if (matches.size > 1) { - if (foldersOnly) { - throw duplicateDriveFolderException(fileName) - } - - throw duplicateDriveFileException(fileName) - } - - return matches.firstOrNull() - } - - private suspend fun runDriveOperation( - interactive: Boolean, - onError: (Throwable) -> CloudStorageException, - bindAccountOnSuccess: (T) -> Boolean = { false }, - block: suspend (token: String) -> T, - ): T { - val started = monotonicTimeMs() - val firstAccess = - try { - verifiedDriveAccessToken(interactive) - } catch (error: Throwable) { - if (error is CancellationException) throw error - logDriveWarning("failed to get google drive access token", error) - throw onError(error) - } - - try { - return finishDriveOperation( - started = started, - access = firstAccess, - result = block(firstAccess.token), - bindAccountOnSuccess = bindAccountOnSuccess, - ) - } catch (error: Throwable) { - if (error is CancellationException) throw error - if (error is DriveHttpException && error.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) { - runCatching { - driveAuthorization.clearToken(firstAccess.token) - }.onFailure { tokenError -> - logDriveWarning("failed to clear expired drive token", tokenError) - } - - val retryAccess = - try { - verifiedDriveAccessToken(interactive) - } catch (retryTokenError: Throwable) { - if (retryTokenError is CancellationException) throw retryTokenError - logDriveWarning("failed to refresh google drive access token", retryTokenError) - throw onError(retryTokenError) - } - - try { - return finishDriveOperation( - started = started, - access = retryAccess, - result = block(retryAccess.token), - bindAccountOnSuccess = bindAccountOnSuccess, - retry = true, - ) - } catch (retryError: Throwable) { - if (retryError is CancellationException) throw retryError - clearRejectedAuthorizationToken(retryAccess.token, retryError) - throw onError(retryError) - } - } - - clearRejectedAuthorizationToken(firstAccess.token, error) - throw onError(error) - } - } - - private suspend fun verifiedDriveAccessToken(interactive: Boolean): DriveAccessToken = - verifiedDriveAccessToken(interactive, retryIdentityLookup = true) - - private suspend fun verifiedDriveAccessToken( - interactive: Boolean, - retryIdentityLookup: Boolean, - ): DriveAccessToken { - val unresolvedAccess = driveAuthorization.accessToken(interactive) - val access = - try { - unresolvedAccess.withResolvedAccountIdentity() - } catch (error: Throwable) { - if (error is CancellationException) throw error - val tokenWasCleared = clearFailedIdentityLookupToken(unresolvedAccess.token, error) - if (tokenWasCleared && retryIdentityLookup) { - return verifiedDriveAccessToken(interactive, retryIdentityLookup = false) - } - - throw error - } - if (access != unresolvedAccess) { - driveAuthorization.updateCachedToken(access) - } - - try { - verifyDriveAccountBinding(accountBindingStore, access.account, bindIfMissing = false) - } catch (error: DriveAccountBindingException) { - runCatching { - driveAuthorization.clearToken(access.token) - }.onFailure { tokenError -> - logDriveWarning("failed to clear mismatched drive token", tokenError) - } - - throw error - } - - return access - } - - private suspend fun DriveAccessToken.withResolvedAccountIdentity(): DriveAccessToken { - val selectedAccount = accountBindingStore.selectedIdentity() - if (account?.isComplete == true && !account.missingSelectedDrivePermissionId(selectedAccount)) { - return this - } - - val resolvedAccount = driveAccountIdentity(token) - val mergedAccount = account?.withMissingFieldsFrom(resolvedAccount) ?: resolvedAccount - - return if (mergedAccount == account) { - this - } else { - copy(account = mergedAccount) - } - } - - private fun DriveAccountIdentity.missingSelectedDrivePermissionId( - selectedAccount: DriveAccountIdentity?, - ): Boolean = - drivePermissionId == null && selectedAccount?.drivePermissionId != null - - private suspend fun driveAccountIdentity(token: String): DriveAccountIdentity? { - try { - return driveAccountIdentityFromAboutResponse( - driveRequest( - token = token, - method = "GET", - url = driveApiUrl( - driveApiEndpoints.aboutEndpoint, - listOf("fields" to "user(emailAddress,permissionId)"), - ), - ).asJsonObject(), - ) - } catch (error: Throwable) { - if (error is CancellationException) throw error - - throw DriveAccountIdentityLookupException(error) - } - } - - private suspend fun clearFailedIdentityLookupToken(token: String, error: Throwable): Boolean { - val lookupError = (error as? DriveAccountIdentityLookupException)?.cause ?: error - val logMessage = - when { - lookupError is DriveHttpException && - lookupError.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED -> - "failed to clear expired drive token" - lookupError is DriveHttpException && - lookupError.statusCode == HttpURLConnection.HTTP_FORBIDDEN && - lookupError.isAuthorizationRejected() -> - "failed to clear rejected drive token" - else -> return false - } - - runCatching { - driveAuthorization.clearToken(token) - }.onFailure { tokenError -> - logDriveWarning(logMessage, tokenError) - } - - return true - } - - private suspend fun finishDriveOperation( - started: Long, - access: DriveAccessToken, - result: T, - bindAccountOnSuccess: (T) -> Boolean, - retry: Boolean = false, - ): T { - if (bindAccountOnSuccess(result)) { - bindDriveAccountAfterSuccessfulOperation(access) - } - - val retryLabel = if (retry) " retry" else "" - logDriveDebug("drive operation$retryLabel elapsed_ms=${monotonicTimeMs() - started}") - return result - } - - private suspend fun bindDriveAccountAfterSuccessfulOperation(access: DriveAccessToken) { - try { - verifyDriveAccountBinding(accountBindingStore, access.account) - } catch (error: DriveAccountBindingException) { - runCatching { - driveAuthorization.clearToken(access.token) - }.onFailure { tokenError -> - logDriveWarning("failed to clear mismatched drive token", tokenError) - } - - throw error - } - } - - private suspend fun clearRejectedAuthorizationToken(token: String, error: Throwable) { - if ( - error is DriveHttpException && - error.statusCode == HttpURLConnection.HTTP_FORBIDDEN && - error.isAuthorizationRejected() - ) { - runCatching { - driveAuthorization.clearToken(token) - }.onFailure { tokenError -> - logDriveWarning("failed to clear rejected drive token", tokenError) - } - } - } - - private suspend fun driveRequest( - token: String, - method: String, - url: String, - body: ByteArray? = null, - contentType: String? = null, - ): DriveResponse = - withContext(Dispatchers.IO) { - val connection = (URL(url).openConnection() as HttpURLConnection) - try { - connection.requestMethod = method - connection.connectTimeout = NETWORK_TIMEOUT_MS - connection.readTimeout = NETWORK_TIMEOUT_MS - connection.setRequestProperty("Authorization", "Bearer $token") - connection.setRequestProperty("Accept", "application/json") - - if (body != null) { - connection.doOutput = true - connection.setRequestProperty("Content-Type", contentType) - connection.outputStream.use { output -> - output.write(body) - } - } - - val statusCode = connection.responseCode - val stream = - if (statusCode in 200..299) { - connection.inputStream - } else { - connection.errorStream ?: connection.inputStream - } - - val responseBody = stream?.use { input -> input.readBytes() } ?: ByteArray(0) - - if (statusCode !in 200..299) { - val responseText = responseBody.toString(Charsets.UTF_8) - logDriveWarning( - "google drive request failed method=$method status=$statusCode url=$url", - ) - throw DriveHttpException(statusCode, responseText) - } - - DriveResponse(statusCode, responseBody) - } finally { - connection.disconnect() - } + folderResolver.listNamespaces(token) } - private fun DriveResponse.asJsonObject(): JSONObject = - if (body.isEmpty()) { - JSONObject() - } else { - JSONObject(body.toString(Charsets.UTF_8)) - } - - private data class DriveResponse( - val statusCode: Int, - val body: ByteArray, - ) - - private data class DriveFileMetadata( - val id: String, - val name: String, - val mimeType: String, - ) { - val isFolder: Boolean - get() = mimeType == DriveApi.FOLDER_MIME_TYPE - } - - private fun List.singleFolderChild(folderName: String): DriveFileMetadata? { - val matches = filter { it.isFolder && it.name == folderName } - if (matches.size > 1) { - throw duplicateDriveFolderException(folderName) - } - - return matches.firstOrNull() - } - - private fun List.backupFileLocations( - locationForFileName: (String) -> String, - ): List = - driveBackupFileLocations( - fileNames = filterNot { it.isFolder }.map { it.name }, - locationForFileName = locationForFileName, - ) - private data class DriveDeleteFailure( val fileId: String, val error: Throwable, @@ -1016,16 +288,6 @@ class AndroidCloudStorageAccess internal constructor( else -> "${error::class.java.simpleName}: ${error.message ?: "no message"}" } - - private object DriveApi { - const val FOLDER_MIME_TYPE = "application/vnd.google-apps.folder" - } - - companion object { - private const val NETWORK_TIMEOUT_MS = 30_000 - private const val APP_DATA_FOLDER = "appDataFolder" - private const val APP_DATA_SPACE = "appDataFolder" - } } internal const val DRIVE_ABOUT_ENDPOINT = "https://www.googleapis.com/drive/v3/about" diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolver.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolver.kt new file mode 100644 index 000000000..ce15893d8 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolver.kt @@ -0,0 +1,532 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.net.HttpURLConnection +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.bitcoinppl.cove_core.device.RemoteBackupLocation +import org.json.JSONArray +import org.json.JSONObject + +internal class DriveFolderResolver( + private val httpClient: DriveHttpClient, + drivePathNamesProvider: () -> DrivePathNames = { DrivePaths.defaultNames }, +) { + private val tree = DriveFolderTree(httpClient, drivePathNamesProvider) + private val fileLocator = DriveFileLocator(tree) + private val backupLocationLister = DriveBackupLocationLister(tree) + + suspend fun ensureNamespaceFolderId( + token: String, + namespace: String, + ): String = + tree.ensureNamespaceFolderId(token, namespace) + + suspend fun findNamespaceFolderId( + token: String, + namespace: String, + ): String? = + tree.findNamespaceFolderId(token, namespace) + + suspend fun requireNamespaceFolderId( + token: String, + namespace: String, + ): String = + tree.requireNamespaceFolderId(token, namespace) + + suspend fun ensureLocationParentFolderId( + token: String, + namespaceFolderId: String, + location: RemoteBackupLocation, + ): String { + var parentId = namespaceFolderId + for (folderName in location.parts.parentFolders) { + parentId = + tree.ensureChildFolderId( + token = token, + parentId = parentId, + folderName = folderName, + ) + } + + return parentId + } + + suspend fun findFilesAtLocations( + token: String, + namespaceFolderId: String, + locations: List, + ): List = + fileLocator.findFilesAtLocations(token, namespaceFolderId, locations) + + suspend fun findFileAtLocations( + token: String, + namespaceFolderId: String, + locations: List, + ): DriveFileMetadata? = + fileLocator.findFileAtLocations(token, namespaceFolderId, locations) + + suspend fun upsertFile( + token: String, + parentId: String, + fileName: String, + data: ByteArray, + ) { + val existing = + tree.findChildByName( + token = token, + parentId = parentId, + fileName = fileName, + ) + + val metadata = + if (existing == null) { + createUploadMetadata(fileName, parentId).toJson() + } else { + overwriteUploadMetadata(fileName).toJson() + } + + val boundary = "cove-${System.currentTimeMillis()}" + val body = httpClient.buildMultipartBody(boundary, metadata, data) + val url = + if (existing == null) { + "${httpClient.endpoints.uploadEndpoint}?uploadType=multipart" + } else { + "${httpClient.endpoints.uploadEndpoint}/${existing.id}?uploadType=multipart" + } + + httpClient.driveRequest( + token = token, + method = if (existing == null) "POST" else "PATCH", + url = url, + body = body, + contentType = "multipart/related; boundary=$boundary", + ) + } + + suspend fun listWalletFiles( + token: String, + namespace: String, + ): List = + backupLocationLister.listWalletFiles(token, namespace) + + suspend fun listNamespaceBackupLocations(token: String): List> = + backupLocationLister.listNamespaceBackupLocations(token) + + suspend fun listNamespaces(token: String): List = + backupLocationLister.listNamespaces(token) + + suspend fun deleteNamespace( + token: String, + namespace: String, + ) { + tree.deleteNamespace(token, namespace) + } +} + +private class DriveFolderTree( + private val httpClient: DriveHttpClient, + drivePathNamesProvider: () -> DrivePathNames, +) { + private val namespacesRootFolderMutex = Mutex() + private val namespaceFolderMutexes = ConcurrentHashMap() + private val childFolderMutexes = ConcurrentHashMap() + val drivePathNames: DrivePathNames by lazy(drivePathNamesProvider) + + suspend fun ensureNamespaceFolderId( + token: String, + namespace: String, + ): String { + val rootId = ensureNamespacesRootFolderId(token) + val mutex = namespaceFolderMutexes.computeIfAbsent(namespace) { Mutex() } + return mutex.withLock { + findChildByName( + token = token, + parentId = rootId, + fileName = namespace, + foldersOnly = true, + )?.id ?: run { + val createdId = createFolder(token, rootId, namespace) + findChildByName( + token = token, + parentId = rootId, + fileName = namespace, + foldersOnly = true, + )?.id ?: createdId + } + } + } + + suspend fun findNamespaceFolderId( + token: String, + namespace: String, + ): String? { + val rootId = findNamespacesRootFolderId(token) ?: return null + return findChildByName( + token = token, + parentId = rootId, + fileName = namespace, + foldersOnly = true, + )?.id + } + + suspend fun requireNamespaceFolderId( + token: String, + namespace: String, + ): String { + val rootId = + findNamespacesRootFolderId(token) + ?: throw DriveHttpException(HttpURLConnection.HTTP_NOT_FOUND, "namespaces root not found") + return findChildByName( + token = token, + parentId = rootId, + fileName = namespace, + foldersOnly = true, + )?.id ?: throw DriveHttpException(HttpURLConnection.HTTP_NOT_FOUND, "namespace not found") + } + + suspend fun ensureChildFolderId( + token: String, + parentId: String, + folderName: String, + ): String { + val mutex = childFolderMutexes.computeIfAbsent("$parentId/$folderName") { Mutex() } + return mutex.withLock { + findChildByName( + token = token, + parentId = parentId, + fileName = folderName, + foldersOnly = true, + )?.id ?: run { + val createdId = createFolder(token, parentId, folderName) + findChildByName( + token = token, + parentId = parentId, + fileName = folderName, + foldersOnly = true, + )?.id ?: createdId + } + } + } + + suspend fun findNamespacesRootFolderId(token: String): String? = + findChildByName( + token = token, + parentId = APP_DATA_FOLDER, + fileName = drivePathNames.namespacesRootFolderName, + foldersOnly = true, + )?.id + + suspend fun listChildren( + token: String, + parentId: String, + foldersOnly: Boolean, + ): List { + val query = + buildString { + append("'") + append(parentId) + append("' in parents and trashed = false") + if (foldersOnly) { + append(" and mimeType = '") + append(DriveApi.FOLDER_MIME_TYPE) + append("'") + } + } + + val children = mutableListOf() + var pageToken: String? = null + + do { + val parameters = + buildList { + add("spaces" to APP_DATA_SPACE) + add("fields" to "nextPageToken,files(id,name,mimeType)") + add("pageSize" to "1000") + add("q" to query) + pageToken?.let { add("pageToken" to it) } + } + + val response = + httpClient.driveRequest( + token = token, + method = "GET", + url = driveApiUrl(httpClient.endpoints.filesEndpoint, parameters), + ).asJsonObject() + + val files = response.optJSONArray("files") ?: JSONArray() + for (index in 0 until files.length()) { + val file = files.getJSONObject(index) + children.add( + DriveFileMetadata( + id = file.getString("id"), + name = file.getString("name"), + mimeType = file.optString("mimeType"), + ), + ) + } + + pageToken = response.optString("nextPageToken").takeIf(String::isNotBlank) + } while (pageToken != null) + + return children + } + + suspend fun findChildByName( + token: String, + parentId: String, + fileName: String, + foldersOnly: Boolean = false, + ): DriveFileMetadata? { + val matches = + listChildren(token, parentId, foldersOnly) + .filter { it.name == fileName && (foldersOnly || !it.isFolder) } + if (matches.size > 1) { + if (foldersOnly) { + throw duplicateDriveFolderException(fileName) + } + + throw duplicateDriveFileException(fileName) + } + + return matches.firstOrNull() + } + + suspend fun deleteNamespace( + token: String, + namespace: String, + ) { + val mutex = namespaceFolderMutexes.computeIfAbsent(namespace) { Mutex() } + mutex.withLock { + val namespaceFolderId = + findNamespaceFolderId(token, namespace) + ?: throw DriveHttpException(HttpURLConnection.HTTP_NOT_FOUND, "namespace not found") + + httpClient.driveRequest( + token = token, + method = "DELETE", + url = "${httpClient.endpoints.filesEndpoint}/$namespaceFolderId", + ) + } + } + + private suspend fun ensureNamespacesRootFolderId(token: String): String = + namespacesRootFolderMutex.withLock { + findNamespacesRootFolderId(token) + ?: run { + val createdId = + createFolder( + token = token, + parentId = APP_DATA_FOLDER, + folderName = drivePathNames.namespacesRootFolderName, + ) + findNamespacesRootFolderId(token) ?: createdId + } + } + + private suspend fun createFolder( + token: String, + parentId: String, + folderName: String, + ): String { + val metadata = + JSONObject() + .put("name", folderName) + .put("mimeType", DriveApi.FOLDER_MIME_TYPE) + .put("parents", JSONArray().put(parentId)) + + val response = + httpClient.driveRequest( + token = token, + method = "POST", + url = httpClient.endpoints.filesEndpoint, + body = metadata.toString().toByteArray(), + contentType = "application/json; charset=utf-8", + ).asJsonObject() + + return response.getString("id") + } + + private companion object { + const val APP_DATA_FOLDER = "appDataFolder" + const val APP_DATA_SPACE = "appDataFolder" + } +} + +private class DriveFileLocator( + private val tree: DriveFolderTree, +) { + suspend fun findFilesAtLocations( + token: String, + namespaceFolderId: String, + locations: List, + ): List = + locations + .mapNotNull { location -> + findFileAtLocation( + token = token, + namespaceFolderId = namespaceFolderId, + location = location, + ) + } + .distinctBy { it.id } + + suspend fun findFileAtLocations( + token: String, + namespaceFolderId: String, + locations: List, + ): DriveFileMetadata? = + locations.firstNotNullOfOrNull { location -> + findFileAtLocation( + token = token, + namespaceFolderId = namespaceFolderId, + location = location, + ) + } + + private suspend fun findFileAtLocation( + token: String, + namespaceFolderId: String, + location: RemoteBackupLocation, + ): DriveFileMetadata? { + val parts = location.parts + var parentId = namespaceFolderId + for (folderName in parts.parentFolders) { + parentId = + tree.findChildByName( + token = token, + parentId = parentId, + fileName = folderName, + foldersOnly = true, + )?.id ?: return null + } + + return tree.findChildByName( + token = token, + parentId = parentId, + fileName = parts.fileName, + ) + } +} + +private class DriveBackupLocationLister( + private val tree: DriveFolderTree, +) { + suspend fun listWalletFiles( + token: String, + namespace: String, + ): List { + val namespaceFolderId = tree.requireNamespaceFolderId(token, namespace) + return listBackupLocations( + token = token, + namespaceFolderId = namespaceFolderId, + ).filter(tree.drivePathNames::isWalletFile) + } + + suspend fun listNamespaceBackupLocations(token: String): List> { + val namespacesRootId = tree.findNamespacesRootFolderId(token) ?: return emptyList() + + return listNamespaceFolders(token, namespacesRootId) + .filter { namespace -> isValidCloudBackupNamespaceId(namespace.name) } + .map { namespace -> + listBackupLocations( + token = token, + namespaceFolderId = namespace.id, + ) + } + } + + suspend fun listNamespaces(token: String): List { + val namespacesRootId = tree.findNamespacesRootFolderId(token) ?: return emptyList() + return listNamespaceFolders(token, namespacesRootId).map { it.name } + } + + private suspend fun listBackupLocations( + token: String, + namespaceFolderId: String, + ): List { + val immediateChildren = + tree.listChildren( + token = token, + parentId = namespaceFolderId, + foldersOnly = false, + ) + + val locations = + immediateChildren + .backupFileLocations { it } + .toMutableList() + + immediateChildren + .singleFolderChild(tree.drivePathNames.masterKeyFolderName) + ?.let { masterKeyFolder -> + tree.listChildren( + token = token, + parentId = masterKeyFolder.id, + foldersOnly = false, + ).backupFileLocations { "${tree.drivePathNames.masterKeyFolderName}/$it" } + .let(locations::addAll) + } + + immediateChildren + .singleFolderChild(tree.drivePathNames.walletsFolderName) + ?.let { walletsFolder -> + tree.listChildren( + token = token, + parentId = walletsFolder.id, + foldersOnly = false, + ).backupFileLocations(tree.drivePathNames::walletLocationForFileName) + .let(locations::addAll) + } + + return locations + } + + private suspend fun listNamespaceFolders( + token: String, + namespacesRootId: String, + ): List { + val namespaces = + tree.listChildren( + token = token, + parentId = namespacesRootId, + foldersOnly = true, + ) + val duplicates = duplicateDriveFolderNames(namespaces.map { it.name }) + if (duplicates.isNotEmpty()) { + throw duplicateDriveFolderException("namespace") + } + + return namespaces + } +} + +internal data class DriveFileMetadata( + val id: String, + val name: String, + val mimeType: String, +) { + val isFolder: Boolean + get() = mimeType == DriveApi.FOLDER_MIME_TYPE +} + +private fun List.singleFolderChild(folderName: String): DriveFileMetadata? { + val matches = filter { it.isFolder && it.name == folderName } + if (matches.size > 1) { + throw duplicateDriveFolderException(folderName) + } + + return matches.firstOrNull() +} + +private fun List.backupFileLocations( + locationForFileName: (String) -> String, +): List = + driveBackupFileLocations( + fileNames = filterNot { it.isFolder }.map { it.name }, + locationForFileName = locationForFileName, + ) + +private object DriveApi { + const val FOLDER_MIME_TYPE = "application/vnd.google-apps.folder" +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClient.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClient.kt new file mode 100644 index 000000000..6de655eb5 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClient.kt @@ -0,0 +1,108 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +@Suppress("InjectDispatcher") +internal class DriveHttpClient( + val endpoints: DriveApiEndpoints = DriveApiEndpoints(), + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun driveRequest( + token: String, + method: String, + url: String, + body: ByteArray? = null, + contentType: String? = null, + ): DriveResponse = + withContext(ioDispatcher) { + val connection = (URL(url).openConnection() as HttpURLConnection) + try { + connection.requestMethod = method + connection.connectTimeout = NETWORK_TIMEOUT_MS + connection.readTimeout = NETWORK_TIMEOUT_MS + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Accept", "application/json") + + if (body != null) { + connection.doOutput = true + connection.setRequestProperty("Content-Type", contentType) + connection.outputStream.use { output -> + output.write(body) + } + } + + val statusCode = connection.responseCode + val stream = + if (statusCode in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX) { + connection.inputStream + } else { + connection.errorStream ?: connection.inputStream + } + + val responseBody = stream?.use { input -> input.readBytes() } ?: ByteArray(0) + + if (statusCode !in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX) { + val responseText = responseBody.toString(Charsets.UTF_8) + logDriveWarning( + "google drive request failed method=$method status=$statusCode url=$url", + ) + throw DriveHttpException(statusCode, responseText) + } + + DriveResponse(statusCode, responseBody) + } finally { + connection.disconnect() + } + } + + fun buildMultipartBody( + boundary: String, + metadata: JSONObject, + data: ByteArray, + ): ByteArray { + val output = ByteArrayOutputStream() + val prefix = "--$boundary\r\n" + output.write(prefix.toByteArray()) + output.write("Content-Type: application/json; charset=UTF-8\r\n\r\n".toByteArray()) + output.write(metadata.toString().toByteArray()) + output.write("\r\n--$boundary\r\n".toByteArray()) + output.write("Content-Type: application/octet-stream\r\n\r\n".toByteArray()) + output.write(data) + output.write("\r\n--$boundary--\r\n".toByteArray()) + return output.toByteArray() + } + + suspend fun downloadFile( + token: String, + fileId: String, + ): ByteArray = + driveRequest( + token = token, + method = "GET", + url = "${endpoints.filesEndpoint}/$fileId?alt=media", + ).body + + private companion object { + const val NETWORK_TIMEOUT_MS = 30_000 + const val HTTP_SUCCESS_MIN = 200 + const val HTTP_SUCCESS_MAX = 299 + } +} + +internal fun DriveResponse.asJsonObject(): JSONObject = + if (body.isEmpty()) { + JSONObject() + } else { + JSONObject(body.toString(Charsets.UTF_8)) + } + +internal data class DriveResponse( + val statusCode: Int, + val body: ByteArray, +) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProvider.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProvider.kt new file mode 100644 index 000000000..cccb2bc80 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProvider.kt @@ -0,0 +1,219 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.net.HttpURLConnection +import kotlinx.coroutines.CancellationException +import org.bitcoinppl.cove_core.device.CloudStorageException + +@Suppress("InstanceOfCheckForException", "ThrowsCount", "TooGenericExceptionCaught") +internal class DriveTokenProvider( + private val driveAuthorization: DriveAuthorization, + private val accountBindingStore: DriveAccountBindingStore, + private val httpClient: DriveHttpClient, +) { + suspend fun runDriveOperation( + interactive: Boolean, + onError: (Throwable) -> CloudStorageException, + bindAccountOnSuccess: (T) -> Boolean = { false }, + block: suspend (token: String) -> T, + ): T { + val started = monotonicTimeMs() + val firstAccess = + try { + verifiedDriveAccessToken(interactive) + } catch (error: Throwable) { + if (error is CancellationException) throw error + logDriveWarning("failed to get google drive access token", error) + throw onError(error) + } + + try { + return finishDriveOperation( + started = started, + access = firstAccess, + result = block(firstAccess.token), + bindAccountOnSuccess = bindAccountOnSuccess, + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + if (error is DriveHttpException && error.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + runCatching { + driveAuthorization.clearToken(firstAccess.token) + }.onFailure { tokenError -> + logDriveWarning("failed to clear expired drive token", tokenError) + } + + val retryAccess = + try { + verifiedDriveAccessToken(interactive) + } catch (retryTokenError: Throwable) { + if (retryTokenError is CancellationException) throw retryTokenError + logDriveWarning("failed to refresh google drive access token", retryTokenError) + throw onError(retryTokenError) + } + + try { + return finishDriveOperation( + started = started, + access = retryAccess, + result = block(retryAccess.token), + bindAccountOnSuccess = bindAccountOnSuccess, + retry = true, + ) + } catch (retryError: Throwable) { + if (retryError is CancellationException) throw retryError + clearRejectedAuthorizationToken(retryAccess.token, retryError) + throw onError(retryError) + } + } + + clearRejectedAuthorizationToken(firstAccess.token, error) + throw onError(error) + } + } + + private suspend fun verifiedDriveAccessToken(interactive: Boolean): DriveAccessToken = + verifiedDriveAccessToken(interactive, retryIdentityLookup = true) + + private suspend fun verifiedDriveAccessToken( + interactive: Boolean, + retryIdentityLookup: Boolean, + ): DriveAccessToken { + val unresolvedAccess = driveAuthorization.accessToken(interactive) + val access = + try { + unresolvedAccess.withResolvedAccountIdentity() + } catch (error: Throwable) { + if (error is CancellationException) throw error + val tokenWasCleared = clearFailedIdentityLookupToken(unresolvedAccess.token, error) + if (tokenWasCleared && retryIdentityLookup) { + return verifiedDriveAccessToken(interactive, retryIdentityLookup = false) + } + + throw error + } + if (access != unresolvedAccess) { + driveAuthorization.updateCachedToken(access) + } + + try { + verifyDriveAccountBinding(accountBindingStore, access.account, bindIfMissing = false) + } catch (error: DriveAccountBindingException) { + runCatching { + driveAuthorization.clearToken(access.token) + }.onFailure { tokenError -> + logDriveWarning("failed to clear mismatched drive token", tokenError) + } + + throw error + } + + return access + } + + private suspend fun DriveAccessToken.withResolvedAccountIdentity(): DriveAccessToken { + val selectedAccount = accountBindingStore.selectedIdentity() + if (account?.isComplete == true && !account.missingSelectedDrivePermissionId(selectedAccount)) { + return this + } + + val resolvedAccount = driveAccountIdentity(token) + val mergedAccount = account?.withMissingFieldsFrom(resolvedAccount) ?: resolvedAccount + + return if (mergedAccount == account) { + this + } else { + copy(account = mergedAccount) + } + } + + private fun DriveAccountIdentity.missingSelectedDrivePermissionId( + selectedAccount: DriveAccountIdentity?, + ): Boolean = + drivePermissionId == null && selectedAccount?.drivePermissionId != null + + private suspend fun driveAccountIdentity(token: String): DriveAccountIdentity? { + try { + return driveAccountIdentityFromAboutResponse( + httpClient.driveRequest( + token = token, + method = "GET", + url = driveApiUrl( + httpClient.endpoints.aboutEndpoint, + listOf("fields" to "user(emailAddress,permissionId)"), + ), + ).asJsonObject(), + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + + throw DriveAccountIdentityLookupException(error) + } + } + + private suspend fun clearFailedIdentityLookupToken(token: String, error: Throwable): Boolean { + val lookupError = (error as? DriveAccountIdentityLookupException)?.cause ?: error + val logMessage = + when { + lookupError is DriveHttpException && + lookupError.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED -> + "failed to clear expired drive token" + lookupError is DriveHttpException && + lookupError.statusCode == HttpURLConnection.HTTP_FORBIDDEN && + lookupError.isAuthorizationRejected() -> + "failed to clear rejected drive token" + else -> return false + } + + runCatching { + driveAuthorization.clearToken(token) + }.onFailure { tokenError -> + logDriveWarning(logMessage, tokenError) + } + + return true + } + + private suspend fun finishDriveOperation( + started: Long, + access: DriveAccessToken, + result: T, + bindAccountOnSuccess: (T) -> Boolean, + retry: Boolean = false, + ): T { + if (bindAccountOnSuccess(result)) { + bindDriveAccountAfterSuccessfulOperation(access) + } + + val retryLabel = if (retry) " retry" else "" + logDriveDebug("drive operation$retryLabel elapsed_ms=${monotonicTimeMs() - started}") + return result + } + + private suspend fun bindDriveAccountAfterSuccessfulOperation(access: DriveAccessToken) { + try { + verifyDriveAccountBinding(accountBindingStore, access.account) + } catch (error: DriveAccountBindingException) { + runCatching { + driveAuthorization.clearToken(access.token) + }.onFailure { tokenError -> + logDriveWarning("failed to clear mismatched drive token", tokenError) + } + + throw error + } + } + + private suspend fun clearRejectedAuthorizationToken(token: String, error: Throwable) { + if ( + error is DriveHttpException && + error.statusCode == HttpURLConnection.HTTP_FORBIDDEN && + error.isAuthorizationRejected() + ) { + runCatching { + driveAuthorization.clearToken(token) + }.onFailure { tokenError -> + logDriveWarning("failed to clear rejected drive token", tokenError) + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/VerificationSection.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/VerificationSection.kt index c7117aa90..b119c5a71 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/VerificationSection.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/VerificationSection.kt @@ -396,7 +396,7 @@ private fun VerificationFailureContent( } private fun verificationRetryAction(failure: DeepVerificationFailure.Retry): CloudBackupManagerAction = - if (failure.retryContext?.action == CloudBackupRetryAction.VERIFY_DISCOVERABLE) { + if (failure.retryAction == CloudBackupRetryAction.VERIFY_DISCOVERABLE) { CloudBackupManagerAction.StartVerificationDiscoverable( CloudBackupVerificationSource.CLOUD_BACKUP_DETAIL, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/ReceivedTransactionDetails.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/ReceivedTransactionDetails.kt index c4c240579..77f180fcd 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/ReceivedTransactionDetails.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/ReceivedTransactionDetails.kt @@ -34,12 +34,11 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay import org.bitcoinppl.cove.R import org.bitcoinppl.cove_core.TransactionDetails -import java.text.NumberFormat @Composable internal fun ReceivedTransactionDetails( transactionDetails: TransactionDetails, - numberOfConfirmations: Int?, + numberOfConfirmationsFmt: String?, currentFiatFmt: String?, historicalFiatFmt: String?, lockControl: @Composable () -> Unit = {}, @@ -65,9 +64,9 @@ internal fun ReceivedTransactionDetails( fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) - if (numberOfConfirmations != null) { + if (numberOfConfirmationsFmt != null) { Text( - NumberFormat.getNumberInstance().format(numberOfConfirmations), + numberOfConfirmationsFmt, color = fg, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 70368ec7d..e8eacb87f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -3,43 +3,10 @@ package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails import android.content.Intent import android.net.Uri import android.os.SystemClock -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.clickable -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.NorthEast -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.SouthWest import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -58,44 +25,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletManager -import org.bitcoinppl.cove.components.ConfirmationIndicatorView -import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove.ui.theme.ResetStatusBarToTheme -import org.bitcoinppl.cove.ui.theme.coveColors -import org.bitcoinppl.cove.ui.theme.isLight -import org.bitcoinppl.cove.utils.toColor -import org.bitcoinppl.cove.views.AsyncText -import org.bitcoinppl.cove.views.BalanceAutoSizeText -import org.bitcoinppl.cove.views.ImageButton -import org.bitcoinppl.cove_core.HeaderIconPresenter import org.bitcoinppl.cove_core.TransactionDetails -import org.bitcoinppl.cove_core.TransactionLockState -import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction -import org.bitcoinppl.cove_core.types.FfiColorScheme import org.bitcoinppl.cove_core.types.TxId -import org.bitcoinppl.cove_core.types.TransactionDirection private const val INITIAL_DELAY_MS = 2000L private const val LOCK_STATE_UPDATE_REVEAL_DELAY_MS = 200L @@ -139,9 +82,6 @@ fun TransactionDetailsScreen( var totalSpentFiatFmt by remember { mutableStateOf(transactionDetails.amountFiatFmtCached()) } var historicalFiatFmt by remember { mutableStateOf(transactionDetails.historicalFiatFmtCached()) } - // get current color scheme (respects in-app theme toggle) - val isDark = !MaterialTheme.colorScheme.isLight - val snackbarHostState = remember { SnackbarHostState() } val transactionLockUpdateErrorMessage = stringResource(R.string.snackbar_transaction_lock_update_error) @@ -149,17 +89,18 @@ fun TransactionDetailsScreen( stringResource(R.string.snackbar_transaction_lock_load_error) suspend fun refreshTransactionLockState(showSnackbar: Boolean) { - try { - manager.transactionLockState(txId) - lockStateLoadFailed = false - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "error fetching transaction lock state", e) - lockStateLoadFailed = true - if (showSnackbar) { - snackbarHostState.showSnackbar(transactionLockLoadErrorMessage) + val result = + runCatchingCancellable("TransactionDetails", "error fetching transaction lock state") { + manager.transactionLockState(txId) } + if (result.isSuccess) { + lockStateLoadFailed = false + return + } + + lockStateLoadFailed = true + if (showSnackbar) { + snackbarHostState.showSnackbar(transactionLockLoadErrorMessage) } } @@ -235,14 +176,10 @@ fun TransactionDetailsScreen( // immediately fetch fresh transaction details on screen load LaunchedEffect(manager, txId, refreshOnAppear) { - try { - if (refreshOnAppear) { + if (refreshOnAppear) { + runCatchingCancellable("TransactionDetails", "error fetching fresh details") { refreshTransactionDetails() } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "error fetching fresh details", e) } refreshTransactionLockState(showSnackbar = false) @@ -254,39 +191,27 @@ fun TransactionDetailsScreen( // load fiat amounts (update cached values with fresh async values) LaunchedEffect(transactionDetails) { feeFiatFmt = - try { + runCatchingCancellable("TransactionDetails", "Failed to fetch fiat fee amount") { transactionDetails.feeFiatFmt() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "Failed to fetch fiat fee amount", e) + }.getOrElse { feeFiatFmt // keep cached value on error } sentSansFeeFiatFmt = - try { + runCatchingCancellable("TransactionDetails", "Failed to fetch sent sans fee fiat amount") { transactionDetails.sentSansFeeFiatFmt() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "Failed to fetch sent sans fee fiat amount", e) + }.getOrElse { sentSansFeeFiatFmt // keep cached value on error } totalSpentFiatFmt = - try { + runCatchingCancellable("TransactionDetails", "Failed to fetch total fiat amount") { transactionDetails.amountFiatFmt() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "Failed to fetch total fiat amount", e) + }.getOrElse { totalSpentFiatFmt // keep cached value on error } historicalFiatFmt = - try { + runCatchingCancellable("TransactionDetails", "Failed to fetch historical fiat amount") { transactionDetails.historicalFiatFmt() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "Failed to fetch historical fiat amount", e) + }.getOrElse { historicalFiatFmt // keep cached value on error } } @@ -294,96 +219,6 @@ fun TransactionDetailsScreen( // theme colors val bg = MaterialTheme.colorScheme.background val fg = MaterialTheme.colorScheme.onBackground - val sub = MaterialTheme.colorScheme.onSurfaceVariant - - // derive UI state from transaction details - val isSent = transactionDetails.isSent() - val isReceived = transactionDetails.isReceived() - val isConfirmed = transactionDetails.isConfirmed() - - // header icon presenter for dynamic colors - val presenter = remember { HeaderIconPresenter() } - val txState = if (isConfirmed) TransactionState.CONFIRMED else TransactionState.PENDING - val direction = if (isSent) TransactionDirection.OUTGOING else TransactionDirection.INCOMING - val colorScheme = if (isDark) FfiColorScheme.DARK else FfiColorScheme.LIGHT - val confirmationCount = numberOfConfirmations?.toLong() ?: if (isConfirmed) 5L else 0L - - // get colors from presenter (matching iOS) - val circleColor = presenter.backgroundColor(txState, direction, colorScheme, confirmationCount).toColor() - val iconColor = presenter.iconColor(txState, direction, colorScheme, confirmationCount).toColor() - - // get ring colors (matching iOS opacities) - val ringOpacities = if (isDark) listOf(0.88f, 0.66f, 0.33f) else listOf(0.44f, 0.24f, 0.10f) - val ringColors: List = - listOf( - presenter - .ringColor(txState, colorScheme, direction, confirmationCount, 1L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[0]) }, - presenter - .ringColor(txState, colorScheme, direction, confirmationCount, 2L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[1]) }, - presenter - .ringColor(txState, colorScheme, direction, confirmationCount, 3L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[2]) }, - ) - - val headerTitle = - stringResource( - id = - if (isConfirmed) { - if (isSent) R.string.title_transaction_sent else R.string.title_transaction_received - } else { - R.string.title_transaction_pending - }, - ) - - val actionLabelRes = - when { - isConfirmed && isSent -> R.string.label_transaction_sent - isConfirmed && !isSent -> R.string.label_transaction_received - !isConfirmed && isSent -> R.string.label_transaction_sending - else -> R.string.label_transaction_receiving - } - val actionIcon = if (isSent) Icons.Filled.NorthEast else Icons.Filled.SouthWest - - // format date - val formattedDate = transactionDetails.confirmationDateTime() ?: "" - - // format amounts - val txAmountPrimary = transactionDetails.displayAmount(metadata = metadata) - - // details expanded from metadata - val isExpanded = metadata.detailsExpanded - - // get capsule config for transaction status - val systemGreen = if (isDark) CoveColor.SystemGreenDark else CoveColor.SystemGreenLight - val capsuleConfig = - when { - isReceived && isConfirmed -> { - Triple( - systemGreen.copy(alpha = 0.2f), - systemGreen, - false, - ) - } - isSent && isConfirmed -> { - Triple( - Color.Black, - Color.White, - true, - ) - } - else -> { - Triple( - CoveColor.coolGray, - Color.Black.copy(alpha = 0.8f), - false, - ) - } - } if (showUnlockLockedUtxosConfirmation) { AlertDialog( @@ -436,482 +271,43 @@ fun TransactionDetailsScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - // parallax + fade effect on background - val scrollState = rememberScrollState() - val scrollOffset = scrollState.value.toFloat() - val parallaxOffset = (-60).dp - (scrollOffset * 0.3f).dp - val fadeAlpha = (1f - (scrollOffset / 275f)).coerceIn(0f, if (isDark) 1.0f else 0.40f) - - PullToRefreshBox( + TransactionDetailsRefreshContent( isRefreshing = isRefreshing, + padding = padding, + transactionDetails = transactionDetails, + manager = manager, + metadata = metadata, + numberOfConfirmations = numberOfConfirmations, + feeFiatFmt = feeFiatFmt, + sentSansFeeFiatFmt = sentSansFeeFiatFmt, + totalSpentFiatFmt = totalSpentFiatFmt, + historicalFiatFmt = historicalFiatFmt, + snackbarHostState = snackbarHostState, + lockState = lockState, + lockStateLoadFailed = lockStateLoadFailed, + isUpdatingLockState = isUpdatingLockState, + showLockStateUpdatingIndicator = showLockStateUpdatingIndicator, onRefresh = { scope.launch { isRefreshing = true - try { + runCatchingCancellable("TransactionDetails", "error refreshing details") { refreshTransactionDetails() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "error refreshing details", e) } refreshTransactionLockState(showSnackbar = true) isRefreshing = false } }, - modifier = - Modifier - .fillMaxSize() - .padding(bottom = padding.calculateBottomPadding()), - ) { - BoxWithConstraints( - modifier = Modifier.fillMaxSize(), - ) { - val minHeight = maxHeight - - Image( - painter = painterResource(id = R.drawable.image_chain_code_pattern_horizontal), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxHeight() - .align(Alignment.TopCenter) - .offset(y = parallaxOffset) - .alpha(fadeAlpha), - ) - - Column( - modifier = - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = minHeight) - .padding(horizontal = 20.dp) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // add top padding to account for top bar - Spacer(Modifier.height(padding.calculateTopPadding() + 16.dp)) - - val configuration = LocalConfiguration.current - val headerSize = (configuration.screenWidthDp * 0.33f).dp - - CheckWithRingsWidget( - diameter = headerSize, - circleColor = circleColor, - ringColors = ringColors, - iconColor = iconColor, - isConfirmed = isConfirmed, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - headerTitle, - color = fg, - fontSize = 28.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 32.sp, - ) - - Spacer(Modifier.height(4.dp)) - - TransactionDetailsHeaderLabelRow( - transactionDetails = transactionDetails, - manager = manager, - secondaryColor = sub, - snackbarHostState = snackbarHostState, - lockState = lockState, - updating = isUpdatingLockState, - onRequestUnlock = { showUnlockLockedUtxosConfirmation = true }, - ) - - Spacer(Modifier.height(24.dp)) - - // show status message with date - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - if (isConfirmed && formattedDate.isNotEmpty()) { - Text( - text = - if (isSent) { - "Your transaction was sent on" - } else { - "Your transaction was successfully received" - }, - color = sub, - fontSize = 17.sp, - textAlign = TextAlign.Center, - lineHeight = 23.sp, - ) - Text( - text = formattedDate, - color = sub, - fontSize = 17.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - lineHeight = 23.sp, - ) - } else if (!isConfirmed) { - Text( - text = "Your transaction is pending.", - color = sub, - fontSize = 17.sp, - textAlign = TextAlign.Center, - lineHeight = 23.sp, - ) - Text( - text = "Please check back soon for an update.", - color = sub, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - lineHeight = 23.sp, - ) - } - } - - Spacer(Modifier.height(32.dp)) - - BalanceAutoSizeText( - txAmountPrimary, - color = fg, - baseFontSize = 34.sp, - minimumScaleFactor = 0.01f, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(Modifier.height(4.dp)) - - AsyncText( - text = totalSpentFiatFmt, - color = fg.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodyLarge, - ) - - Spacer(Modifier.height(32.dp)) - - TransactionCapsule( - text = stringResource(actionLabelRes), - icon = actionIcon, - backgroundColor = capsuleConfig.first, - textColor = capsuleConfig.second, - showStroke = capsuleConfig.third, - ) - - Spacer(Modifier.height(32.dp)) - - // show confirmation indicator if < 3 confirmations - if (numberOfConfirmations != null && numberOfConfirmations < 3) { - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(Modifier.height(24.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant), - ) - Spacer(Modifier.height(24.dp)) - ConfirmationIndicatorView( - current = numberOfConfirmations, - modifier = Modifier.fillMaxWidth(), - ) - // only add spacing if details are collapsed - if (!isExpanded) { - Spacer(Modifier.height(32.dp)) - } - } - } - - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - TransactionDetailsWidget( - transactionDetails = transactionDetails, - numberOfConfirmations = numberOfConfirmations, - feeFiatFmt = feeFiatFmt, - sentSansFeeFiatFmt = sentSansFeeFiatFmt, - totalSpentFiatFmt = totalSpentFiatFmt, - historicalFiatFmt = historicalFiatFmt, - metadata = metadata, - lockControl = { - TransactionLockControls( - state = lockState, - loadFailed = lockStateLoadFailed, - updating = isUpdatingLockState, - showUpdatingIndicator = showLockStateUpdatingIndicator, - color = sub, - onRetry = ::retryTransactionLockState, - onToggle = ::toggleTransactionLockState, - ) - }, - ) - } - - // flexible spacer to push buttons to bottom (matches iOS Spacer() behavior) - Spacer(Modifier.weight(1f)) - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ImageButton( - text = stringResource(R.string.btn_view_in_explorer), - onClick = { - val url = transactionDetails.transactionUrl() - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.coveColors.midnightBtn, - contentColor = Color.White, - ), - fontSize = 17.sp, - modifier = - Modifier - .fillMaxWidth(), - ) - - Spacer(Modifier.height(16.dp)) - - TextButton( - onClick = { - manager.dispatch(WalletManagerAction.ToggleDetailsExpanded) - }, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .offset(y = (-20).dp), - ) { - Text( - text = stringResource(if (isExpanded) R.string.btn_hide_details else R.string.btn_show_details), - color = sub.copy(alpha = 0.8f), - fontSize = 13.sp, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ) - } - } - } - } - } - } -} - -@Composable -private fun TransactionDetailsHeaderLabelRow( - transactionDetails: TransactionDetails, - manager: WalletManager, - secondaryColor: Color, - snackbarHostState: SnackbarHostState, - lockState: TransactionLockState?, - updating: Boolean, - onRequestUnlock: () -> Unit, -) { - val collapsedLockState = - lockState - ?.takeIf { it.showsCollapsedLockTreatment } - - if (collapsedLockState == null) { - TransactionLabelView( - transactionDetails = transactionDetails, - manager = manager, - secondaryColor = secondaryColor, - snackbarHostState = snackbarHostState, - ) - return - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - TransactionLabelView( - transactionDetails = transactionDetails, - manager = manager, - secondaryColor = secondaryColor, - snackbarHostState = snackbarHostState, - ) - - TransactionLockedUtxosBadge( - state = collapsedLockState, - updating = updating, - onClick = onRequestUnlock, - ) - } -} - -@Composable -private fun TransactionLockedUtxosBadge( - state: TransactionLockState, - updating: Boolean, - onClick: () -> Unit, -) { - val textRes = - when (state) { - TransactionLockState.LOCKED -> R.string.label_transaction_utxos_locked - TransactionLockState.MIXED -> R.string.label_transaction_utxos_some_locked - TransactionLockState.NONE, TransactionLockState.UNLOCKED -> return - } - - Row( - modifier = - Modifier - .alpha(if (updating) 0.72f else 1f) - .clip(RoundedCornerShape(percent = 50)) - .background(CoveColor.ErrorRed.copy(alpha = 0.14f)) - .clickable(enabled = !updating, onClick = onClick) - .padding(horizontal = 9.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = CoveColor.ErrorRed, - modifier = Modifier.size(13.dp), - ) - - Text( - text = stringResource(textRes), - color = CoveColor.ErrorRed, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -private val TransactionLockState.showsCollapsedLockTreatment: Boolean - get() = this == TransactionLockState.LOCKED || this == TransactionLockState.MIXED - -@Composable -private fun TransactionLockControls( - state: TransactionLockState?, - loadFailed: Boolean, - updating: Boolean, - showUpdatingIndicator: Boolean, - color: Color, - onRetry: () -> Unit, - onToggle: () -> Unit, -) { - val actionState = state?.takeUnless { it == TransactionLockState.NONE } - - when { - loadFailed -> { - TransactionLockLoadError( - color = color, - onRetry = onRetry, - ) - } - - actionState != null -> { - TransactionLockAction( - state = actionState, - updating = updating, - showUpdatingIndicator = showUpdatingIndicator, - color = color, - lockedColor = CoveColor.ErrorRed, - onToggle = onToggle, - ) - } - } -} - -@Composable -private fun TransactionLockAction( - state: TransactionLockState, - updating: Boolean, - showUpdatingIndicator: Boolean, - color: Color, - lockedColor: Color? = null, - onToggle: () -> Unit, -) { - val buttonText = - when (state) { - TransactionLockState.LOCKED -> stringResource(R.string.btn_unlock) - TransactionLockState.MIXED, TransactionLockState.UNLOCKED -> stringResource(R.string.btn_lock) - TransactionLockState.NONE -> "" - } - val updatingText = stringResource(R.string.label_transaction_lock_updating) - val icon = if (state == TransactionLockState.LOCKED) Icons.Filled.LockOpen else Icons.Filled.Lock - val actionColor = if (state == TransactionLockState.LOCKED) lockedColor ?: color else color - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .clickable(enabled = !updating, onClick = onToggle) - .padding(vertical = 4.dp), - ) { - if (showUpdatingIndicator) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, - color = actionColor.copy(alpha = 0.72f), - ) - } else { - Icon( - imageVector = icon, - contentDescription = null, - tint = actionColor.copy(alpha = 0.72f), - modifier = Modifier.size(12.dp), - ) - } - - Spacer(Modifier.width(6.dp)) - - Text( - text = if (showUpdatingIndicator) updatingText else buttonText, - color = actionColor.copy(alpha = if (updating) 0.72f else 1f), - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } -} - -@Composable -private fun TransactionLockLoadError( - color: Color, - onRetry: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .clickable(onClick = onRetry) - .padding(vertical = 4.dp), - ) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = null, - tint = color.copy(alpha = 0.72f), - modifier = Modifier.size(12.dp), - ) - - Spacer(Modifier.width(6.dp)) - - Text( - text = stringResource(R.string.btn_retry), - color = color, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, + onViewInExplorer = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(transactionDetails.transactionUrl())) + context.startActivity(intent) + }, + onToggleDetails = { + manager.dispatch(WalletManagerAction.ToggleDetailsExpanded) + }, + onRetryTransactionLockState = ::retryTransactionLockState, + onToggleTransactionLockState = ::toggleTransactionLockState, + onRequestUnlockLockedUtxos = { showUnlockLockedUtxosConfirmation = true }, ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsSections.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsSections.kt new file mode 100644 index 000000000..911a75a6f --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsSections.kt @@ -0,0 +1,819 @@ +@file:Suppress("FunctionNaming", "LongParameterList", "PackageNaming", "TooManyFunctions") + +package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.NorthEast +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SouthWest +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.components.ConfirmationIndicatorView +import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.coveColors +import org.bitcoinppl.cove.ui.theme.isLight +import org.bitcoinppl.cove.utils.toColor +import org.bitcoinppl.cove.views.AsyncText +import org.bitcoinppl.cove.views.BalanceAutoSizeText +import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.HeaderIconPresenter +import org.bitcoinppl.cove_core.TransactionDetails +import org.bitcoinppl.cove_core.TransactionLockState +import org.bitcoinppl.cove_core.TransactionState +import org.bitcoinppl.cove_core.WalletMetadata +import org.bitcoinppl.cove_core.types.FfiColorScheme +import org.bitcoinppl.cove_core.types.TransactionDirection + +private const val HEADER_WIDTH_RATIO = 0.33f +private const val CONFIRMATION_INDICATOR_LIMIT = 3 +private const val CONTROL_DISABLED_ALPHA = 0.72f +private const val LOCK_BADGE_BACKGROUND_ALPHA = 0.14f + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TransactionDetailsRefreshContent( + isRefreshing: Boolean, + padding: PaddingValues, + transactionDetails: TransactionDetails, + manager: WalletManager, + metadata: WalletMetadata, + numberOfConfirmations: Int?, + feeFiatFmt: String?, + sentSansFeeFiatFmt: String?, + totalSpentFiatFmt: String?, + historicalFiatFmt: String?, + snackbarHostState: SnackbarHostState, + lockState: TransactionLockState?, + lockStateLoadFailed: Boolean, + isUpdatingLockState: Boolean, + showLockStateUpdatingIndicator: Boolean, + onRefresh: () -> Unit, + onViewInExplorer: () -> Unit, + onToggleDetails: () -> Unit, + onRetryTransactionLockState: () -> Unit, + onToggleTransactionLockState: () -> Unit, + onRequestUnlockLockedUtxos: () -> Unit, +) { + val scrollState = rememberScrollState() + val scrollOffset = scrollState.value.toFloat() + val isDark = !MaterialTheme.colorScheme.isLight + val parallaxOffset = (-60).dp - (scrollOffset * 0.3f).dp + val fadeAlpha = (1f - (scrollOffset / 275f)).coerceIn(0f, if (isDark) 1.0f else 0.40f) + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = + Modifier + .fillMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.image_chain_code_pattern_horizontal), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxHeight() + .align(Alignment.TopCenter) + .offset(y = parallaxOffset) + .alpha(fadeAlpha), + ) + + TransactionDetailsColumn( + minHeight = maxHeight, + topPadding = padding.calculateTopPadding(), + scrollState = scrollState, + transactionDetails = transactionDetails, + manager = manager, + metadata = metadata, + numberOfConfirmations = numberOfConfirmations, + feeFiatFmt = feeFiatFmt, + sentSansFeeFiatFmt = sentSansFeeFiatFmt, + totalSpentFiatFmt = totalSpentFiatFmt, + historicalFiatFmt = historicalFiatFmt, + snackbarHostState = snackbarHostState, + lockState = lockState, + lockStateLoadFailed = lockStateLoadFailed, + isUpdatingLockState = isUpdatingLockState, + showLockStateUpdatingIndicator = showLockStateUpdatingIndicator, + onViewInExplorer = onViewInExplorer, + onToggleDetails = onToggleDetails, + onRetryTransactionLockState = onRetryTransactionLockState, + onToggleTransactionLockState = onToggleTransactionLockState, + onRequestUnlockLockedUtxos = onRequestUnlockLockedUtxos, + ) + } + } +} + +@Composable +private fun TransactionDetailsColumn( + minHeight: Dp, + topPadding: Dp, + scrollState: androidx.compose.foundation.ScrollState, + transactionDetails: TransactionDetails, + manager: WalletManager, + metadata: WalletMetadata, + numberOfConfirmations: Int?, + feeFiatFmt: String?, + sentSansFeeFiatFmt: String?, + totalSpentFiatFmt: String?, + historicalFiatFmt: String?, + snackbarHostState: SnackbarHostState, + lockState: TransactionLockState?, + lockStateLoadFailed: Boolean, + isUpdatingLockState: Boolean, + showLockStateUpdatingIndicator: Boolean, + onViewInExplorer: () -> Unit, + onToggleDetails: () -> Unit, + onRetryTransactionLockState: () -> Unit, + onToggleTransactionLockState: () -> Unit, + onRequestUnlockLockedUtxos: () -> Unit, +) { + val foreground = MaterialTheme.colorScheme.onBackground + val secondary = MaterialTheme.colorScheme.onSurfaceVariant + val isExpanded = metadata.detailsExpanded + val numberOfConfirmationsFmt = + numberOfConfirmations?.let { manager.displayConfirmationCount(it.toUInt()) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = minHeight) + .padding(horizontal = 20.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(topPadding + 16.dp)) + TransactionDetailsHeaderSection( + transactionDetails = transactionDetails, + manager = manager, + numberOfConfirmations = numberOfConfirmations, + lockState = lockState, + isUpdatingLockState = isUpdatingLockState, + secondaryColor = secondary, + snackbarHostState = snackbarHostState, + onRequestUnlockLockedUtxos = onRequestUnlockLockedUtxos, + ) + + TransactionStatusSection(transactionDetails = transactionDetails, secondaryColor = secondary) + TransactionAmountsSection(transactionDetails, metadata, totalSpentFiatFmt, foreground) + TransactionCapsuleSection(transactionDetails) + TransactionConfirmationsSection(numberOfConfirmations, isExpanded) + TransactionExpandedDetailsSection( + isExpanded = isExpanded, + transactionDetails = transactionDetails, + numberOfConfirmationsFmt = numberOfConfirmationsFmt, + feeFiatFmt = feeFiatFmt, + sentSansFeeFiatFmt = sentSansFeeFiatFmt, + totalSpentFiatFmt = totalSpentFiatFmt, + historicalFiatFmt = historicalFiatFmt, + metadata = metadata, + lockState = lockState, + lockStateLoadFailed = lockStateLoadFailed, + isUpdatingLockState = isUpdatingLockState, + showLockStateUpdatingIndicator = showLockStateUpdatingIndicator, + secondaryColor = secondary, + onRetryTransactionLockState = onRetryTransactionLockState, + onToggleTransactionLockState = onToggleTransactionLockState, + ) + + Spacer(Modifier.weight(1f)) + TransactionDetailsActions(isExpanded, secondary, onViewInExplorer, onToggleDetails) + } +} + +@Composable +private fun TransactionDetailsHeaderSection( + transactionDetails: TransactionDetails, + manager: WalletManager, + numberOfConfirmations: Int?, + lockState: TransactionLockState?, + isUpdatingLockState: Boolean, + secondaryColor: Color, + snackbarHostState: SnackbarHostState, + onRequestUnlockLockedUtxos: () -> Unit, +) { + val foreground = MaterialTheme.colorScheme.onBackground + val configuration = LocalConfiguration.current + val headerSize = (configuration.screenWidthDp * HEADER_WIDTH_RATIO).dp + + TransactionDetailsHeaderIcon( + diameter = headerSize, + transactionDetails = transactionDetails, + numberOfConfirmations = numberOfConfirmations, + ) + + Spacer(Modifier.height(16.dp)) + Text( + text = transactionHeaderTitle(transactionDetails), + color = foreground, + fontSize = 28.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 32.sp, + ) + + Spacer(Modifier.height(4.dp)) + TransactionDetailsHeaderLabelRow( + transactionDetails = transactionDetails, + manager = manager, + secondaryColor = secondaryColor, + snackbarHostState = snackbarHostState, + lockState = lockState, + updating = isUpdatingLockState, + onRequestUnlock = onRequestUnlockLockedUtxos, + ) + + Spacer(Modifier.height(24.dp)) +} + +@Composable +private fun TransactionDetailsHeaderIcon( + diameter: Dp, + transactionDetails: TransactionDetails, + numberOfConfirmations: Int?, +) { + val iconColors = transactionHeaderIconColors(transactionDetails, numberOfConfirmations) + + CheckWithRingsWidget( + diameter = diameter, + circleColor = iconColors.circleColor, + ringColors = iconColors.ringColors, + iconColor = iconColors.iconColor, + isConfirmed = transactionDetails.isConfirmed(), + ) +} + +@Composable +private fun transactionHeaderTitle(transactionDetails: TransactionDetails): String = + stringResource( + id = + when { + !transactionDetails.isConfirmed() -> R.string.title_transaction_pending + transactionDetails.isSent() -> R.string.title_transaction_sent + else -> R.string.title_transaction_received + }, + ) + +@Composable +private fun transactionHeaderIconColors( + transactionDetails: TransactionDetails, + numberOfConfirmations: Int?, +): TransactionHeaderIconColors { + val presenter = remember { HeaderIconPresenter() } + val isConfirmed = transactionDetails.isConfirmed() + val txState = if (isConfirmed) TransactionState.CONFIRMED else TransactionState.PENDING + val direction = + if (transactionDetails.isSent()) { + TransactionDirection.OUTGOING + } else { + TransactionDirection.INCOMING + } + val isDark = !MaterialTheme.colorScheme.isLight + val colorScheme = if (isDark) FfiColorScheme.DARK else FfiColorScheme.LIGHT + val confirmationCount = numberOfConfirmations?.toLong() ?: if (isConfirmed) 5L else 0L + val ringOpacities = if (isDark) listOf(0.88f, 0.66f, 0.33f) else listOf(0.44f, 0.24f, 0.10f) + + return TransactionHeaderIconColors( + circleColor = presenter.backgroundColor(txState, direction, colorScheme, confirmationCount).toColor(), + iconColor = presenter.iconColor(txState, direction, colorScheme, confirmationCount).toColor(), + ringColors = + (1L..3L).mapIndexed { index, ringNumber -> + presenter + .ringColor(txState, colorScheme, direction, confirmationCount, ringNumber) + .toColor() + .let { color -> color.copy(alpha = color.alpha * ringOpacities[index]) } + }, + ) +} + +private data class TransactionHeaderIconColors( + val circleColor: Color, + val iconColor: Color, + val ringColors: List, +) + +@Composable +private fun TransactionStatusSection( + transactionDetails: TransactionDetails, + secondaryColor: Color, +) { + val isSent = transactionDetails.isSent() + val isConfirmed = transactionDetails.isConfirmed() + val formattedDate = transactionDetails.confirmationDateTime() ?: "" + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + when { + isConfirmed && formattedDate.isNotEmpty() -> { + TransactionStatusText( + if (isSent) { + "Your transaction was sent on" + } else { + "Your transaction was successfully received" + }, + secondaryColor, + ) + TransactionStatusText(formattedDate, secondaryColor, fontWeight = FontWeight.Medium) + } + !isConfirmed -> { + TransactionStatusText("Your transaction is pending.", secondaryColor) + TransactionStatusText( + "Please check back soon for an update.", + secondaryColor, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + + Spacer(Modifier.height(32.dp)) +} + +@Composable +private fun TransactionStatusText( + text: String, + color: Color, + fontWeight: FontWeight? = null, +) { + Text( + text = text, + color = color, + fontSize = 17.sp, + fontWeight = fontWeight, + textAlign = TextAlign.Center, + lineHeight = 23.sp, + ) +} + +@Composable +private fun TransactionAmountsSection( + transactionDetails: TransactionDetails, + metadata: WalletMetadata, + totalSpentFiatFmt: String?, + foreground: Color, +) { + BalanceAutoSizeText( + transactionDetails.displayAmount(metadata = metadata), + color = foreground, + baseFontSize = 34.sp, + minimumScaleFactor = 0.01f, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(4.dp)) + AsyncText( + text = totalSpentFiatFmt, + color = foreground.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.height(32.dp)) +} + +@Composable +private fun TransactionCapsuleSection(transactionDetails: TransactionDetails) { + val isSent = transactionDetails.isSent() + val capsule = transactionCapsuleStyle(transactionDetails) + + TransactionCapsule( + text = stringResource(capsule.labelRes), + icon = if (isSent) Icons.Filled.NorthEast else Icons.Filled.SouthWest, + backgroundColor = capsule.backgroundColor, + textColor = capsule.textColor, + showStroke = capsule.showStroke, + ) + + Spacer(Modifier.height(32.dp)) +} + +@Composable +private fun transactionCapsuleStyle(transactionDetails: TransactionDetails): TransactionCapsuleStyle { + val isDark = !MaterialTheme.colorScheme.isLight + val isSent = transactionDetails.isSent() + val isConfirmed = transactionDetails.isConfirmed() + val systemGreen = if (isDark) CoveColor.SystemGreenDark else CoveColor.SystemGreenLight + + return when { + transactionDetails.isReceived() && isConfirmed -> + TransactionCapsuleStyle( + labelRes = R.string.label_transaction_received, + backgroundColor = systemGreen.copy(alpha = 0.2f), + textColor = systemGreen, + ) + isSent && isConfirmed -> + TransactionCapsuleStyle( + labelRes = R.string.label_transaction_sent, + backgroundColor = Color.Black, + textColor = Color.White, + showStroke = true, + ) + isSent -> + TransactionCapsuleStyle( + labelRes = R.string.label_transaction_sending, + backgroundColor = CoveColor.coolGray, + textColor = Color.Black.copy(alpha = 0.8f), + ) + else -> + TransactionCapsuleStyle( + labelRes = R.string.label_transaction_receiving, + backgroundColor = CoveColor.coolGray, + textColor = Color.Black.copy(alpha = 0.8f), + ) + } +} + +private data class TransactionCapsuleStyle( + val labelRes: Int, + val backgroundColor: Color, + val textColor: Color, + val showStroke: Boolean = false, +) + +@Composable +private fun TransactionConfirmationsSection( + numberOfConfirmations: Int?, + isExpanded: Boolean, +) { + if (numberOfConfirmations == null || numberOfConfirmations >= CONFIRMATION_INDICATOR_LIMIT) return + + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(24.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + Spacer(Modifier.height(24.dp)) + ConfirmationIndicatorView( + current = numberOfConfirmations, + modifier = Modifier.fillMaxWidth(), + ) + if (!isExpanded) { + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun TransactionExpandedDetailsSection( + isExpanded: Boolean, + transactionDetails: TransactionDetails, + numberOfConfirmationsFmt: String?, + feeFiatFmt: String?, + sentSansFeeFiatFmt: String?, + totalSpentFiatFmt: String?, + historicalFiatFmt: String?, + metadata: WalletMetadata, + lockState: TransactionLockState?, + lockStateLoadFailed: Boolean, + isUpdatingLockState: Boolean, + showLockStateUpdatingIndicator: Boolean, + secondaryColor: Color, + onRetryTransactionLockState: () -> Unit, + onToggleTransactionLockState: () -> Unit, +) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + TransactionDetailsWidget( + transactionDetails = transactionDetails, + numberOfConfirmationsFmt = numberOfConfirmationsFmt, + feeFiatFmt = feeFiatFmt, + sentSansFeeFiatFmt = sentSansFeeFiatFmt, + totalSpentFiatFmt = totalSpentFiatFmt, + historicalFiatFmt = historicalFiatFmt, + metadata = metadata, + lockControl = { + TransactionLockControls( + state = lockState, + loadFailed = lockStateLoadFailed, + updating = isUpdatingLockState, + showUpdatingIndicator = showLockStateUpdatingIndicator, + color = secondaryColor, + onRetry = onRetryTransactionLockState, + onToggle = onToggleTransactionLockState, + ) + }, + ) + } +} + +@Composable +private fun TransactionDetailsActions( + isExpanded: Boolean, + secondaryColor: Color, + onViewInExplorer: () -> Unit, + onToggleDetails: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ImageButton( + text = stringResource(R.string.btn_view_in_explorer), + onClick = onViewInExplorer, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.coveColors.midnightBtn, + contentColor = Color.White, + ), + fontSize = 17.sp, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + TextButton( + onClick = onToggleDetails, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .offset(y = (-20).dp), + ) { + Text( + text = stringResource(if (isExpanded) R.string.btn_hide_details else R.string.btn_show_details), + color = secondaryColor.copy(alpha = 0.8f), + fontSize = 13.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + ) + } + } +} + +@Composable +private fun TransactionDetailsHeaderLabelRow( + transactionDetails: TransactionDetails, + manager: WalletManager, + secondaryColor: Color, + snackbarHostState: SnackbarHostState, + lockState: TransactionLockState?, + updating: Boolean, + onRequestUnlock: () -> Unit, +) { + val collapsedLockState = + lockState + ?.takeIf { it.showsCollapsedLockTreatment } + + if (collapsedLockState == null) { + TransactionLabelView( + transactionDetails = transactionDetails, + manager = manager, + secondaryColor = secondaryColor, + snackbarHostState = snackbarHostState, + ) + return + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TransactionLabelView( + transactionDetails = transactionDetails, + manager = manager, + secondaryColor = secondaryColor, + snackbarHostState = snackbarHostState, + ) + + TransactionLockedUtxosBadge( + state = collapsedLockState, + updating = updating, + onClick = onRequestUnlock, + ) + } +} + +@Composable +private fun TransactionLockedUtxosBadge( + state: TransactionLockState, + updating: Boolean, + onClick: () -> Unit, +) { + val textRes = + when (state) { + TransactionLockState.LOCKED -> R.string.label_transaction_utxos_locked + TransactionLockState.MIXED -> R.string.label_transaction_utxos_some_locked + TransactionLockState.NONE, TransactionLockState.UNLOCKED -> return + } + + Row( + modifier = + Modifier + .alpha(if (updating) CONTROL_DISABLED_ALPHA else 1f) + .clip(RoundedCornerShape(percent = 50)) + .background(CoveColor.ErrorRed.copy(alpha = LOCK_BADGE_BACKGROUND_ALPHA)) + .clickable(enabled = !updating, onClick = onClick) + .padding(horizontal = 9.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CoveColor.ErrorRed, + modifier = Modifier.size(13.dp), + ) + + Text( + text = stringResource(textRes), + color = CoveColor.ErrorRed, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private val TransactionLockState.showsCollapsedLockTreatment: Boolean + get() = this == TransactionLockState.LOCKED || this == TransactionLockState.MIXED + +@Composable +private fun TransactionLockControls( + state: TransactionLockState?, + loadFailed: Boolean, + updating: Boolean, + showUpdatingIndicator: Boolean, + color: Color, + onRetry: () -> Unit, + onToggle: () -> Unit, +) { + val actionState = state?.takeUnless { it == TransactionLockState.NONE } + + when { + loadFailed -> { + TransactionLockLoadError( + color = color, + onRetry = onRetry, + ) + } + + actionState != null -> { + TransactionLockAction( + state = actionState, + updating = updating, + showUpdatingIndicator = showUpdatingIndicator, + color = color, + lockedColor = CoveColor.ErrorRed, + onToggle = onToggle, + ) + } + } +} + +@Composable +private fun TransactionLockAction( + state: TransactionLockState, + updating: Boolean, + showUpdatingIndicator: Boolean, + color: Color, + lockedColor: Color? = null, + onToggle: () -> Unit, +) { + val buttonText = + when (state) { + TransactionLockState.LOCKED -> stringResource(R.string.btn_unlock) + TransactionLockState.MIXED, TransactionLockState.UNLOCKED -> stringResource(R.string.btn_lock) + TransactionLockState.NONE -> "" + } + val updatingText = stringResource(R.string.label_transaction_lock_updating) + val icon = if (state == TransactionLockState.LOCKED) Icons.Filled.LockOpen else Icons.Filled.Lock + val actionColor = if (state == TransactionLockState.LOCKED) lockedColor ?: color else color + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable(enabled = !updating, onClick = onToggle) + .padding(vertical = 4.dp), + ) { + if (showUpdatingIndicator) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = actionColor.copy(alpha = CONTROL_DISABLED_ALPHA), + ) + } else { + Icon( + imageVector = icon, + contentDescription = null, + tint = actionColor.copy(alpha = CONTROL_DISABLED_ALPHA), + modifier = Modifier.size(12.dp), + ) + } + + Spacer(Modifier.width(6.dp)) + + Text( + text = if (showUpdatingIndicator) updatingText else buttonText, + color = actionColor.copy(alpha = if (updating) CONTROL_DISABLED_ALPHA else 1f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun TransactionLockLoadError( + color: Color, + onRetry: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable(onClick = onRetry) + .padding(vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + tint = color.copy(alpha = CONTROL_DISABLED_ALPHA), + modifier = Modifier.size(12.dp), + ) + + Spacer(Modifier.width(6.dp)) + + Text( + text = stringResource(R.string.btn_retry), + color = color, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsWidget.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsWidget.kt index 0f7408106..d4a2605a1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsWidget.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsWidget.kt @@ -32,13 +32,14 @@ import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AsyncText import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.KeyValueRow import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.WalletMetadata @Composable internal fun TransactionDetailsWidget( transactionDetails: TransactionDetails, - numberOfConfirmations: Int?, + numberOfConfirmationsFmt: String?, feeFiatFmt: String?, sentSansFeeFiatFmt: String?, totalSpentFiatFmt: String?, @@ -96,9 +97,9 @@ internal fun TransactionDetailsWidget( fontSize = 12.sp, ) Text(" | ", color = sub, fontSize = 12.sp) - if (numberOfConfirmations != null) { + if (numberOfConfirmationsFmt != null) { Text( - numberOfConfirmations.toString(), + numberOfConfirmationsFmt, color = sub, fontSize = 12.sp, ) @@ -185,7 +186,7 @@ internal fun TransactionDetailsWidget( // received transaction details ReceivedTransactionDetails( transactionDetails = transactionDetails, - numberOfConfirmations = numberOfConfirmations, + numberOfConfirmationsFmt = numberOfConfirmationsFmt, currentFiatFmt = totalSpentFiatFmt, historicalFiatFmt = historicalFiatFmt, lockControl = lockControl, @@ -212,38 +213,53 @@ internal fun DetailsWidget( val labelColor = if (isTotal) fg else sub val primaryColor = if (isTotal) fg else sub - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), - ) { - Text( - label, - color = labelColor, - fontSize = 15.sp, - ) - if (showInfoIcon) { - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = onInfoClick, - modifier = Modifier.size(24.dp), - content = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = sub, - modifier = Modifier.size(16.dp), - ) - }, + KeyValueRow( + modifier = Modifier.fillMaxWidth(), + labelWeight = 1f, + valueWeight = null, + verticalAlignment = Alignment.Top, + labelContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + color = labelColor, + fontSize = 15.sp, ) + if (showInfoIcon) { + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onInfoClick, + modifier = Modifier.size(24.dp), + content = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = sub, + modifier = Modifier.size(16.dp), + ) + }, + ) + } } - } - Column(horizontalAlignment = Alignment.End) { - AutoSizeText(primary, color = primaryColor, maxFontSize = 15.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(6.dp)) - AsyncText(text = secondary, color = sub, style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp)) - } - } + }, + valueContent = { + Column(horizontalAlignment = Alignment.End) { + AutoSizeText( + primary, + color = primaryColor, + maxFontSize = 15.sp, + minimumScaleFactor = 0.90f, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + AsyncText( + text = secondary, + color = sub, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + ) + } + }, + ) } @Composable diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionLabelView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionLabelView.kt index 3ee3ddd67..d064f4a64 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionLabelView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionLabelView.kt @@ -45,10 +45,10 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.types.TxId @@ -91,13 +91,10 @@ fun TransactionLabelView( } suspend fun updateDetails() { - try { + runCatchingCancellable(TAG, "Error getting updated label") { val details = manager.refreshTransactionDetails(txId) currentLabel = details.transactionLabel() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e(TAG, "Error getting updated label", e) + }.onFailure { e -> val message = context.getString(R.string.label_update_error, e.message ?: "Unknown error") snackbarHostState.showSnackbar( message = message, @@ -112,28 +109,27 @@ fun TransactionLabelView( scope.launch { isOperationInProgress = true try { - val metadata = manager.walletMetadata - val label = editingLabel.text.trim() - labelManager.insertOrUpdateLabelsForTxn( - details = transactionDetails, - label = label, - origin = metadata?.origin, - ) + runCatchingCancellable(TAG, "Unable to save label") { + val metadata = manager.walletMetadata + val label = editingLabel.text.trim() + labelManager.insertOrUpdateLabelsForTxn( + details = transactionDetails, + label = label, + origin = metadata?.origin, + ) - currentLabel = label.ifEmpty { null } - isEditing = false - hasFocusedOnce = false + currentLabel = label.ifEmpty { null } + isEditing = false + hasFocusedOnce = false - updateDetails() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e(TAG, "Unable to save label", e) - val message = context.getString(R.string.label_save_error, e.message ?: "Unknown error") - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) + updateDetails() + }.onFailure { e -> + val message = context.getString(R.string.label_save_error, e.message ?: "Unknown error") + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, + ) + } } finally { isOperationInProgress = false } @@ -146,22 +142,21 @@ fun TransactionLabelView( scope.launch { isOperationInProgress = true try { - labelManager.deleteLabelsForTxn(txId = txId) - isEditing = false - hasFocusedOnce = false - editingLabel = TextFieldValue() - currentLabel = null + runCatchingCancellable(TAG, "Unable to delete label") { + labelManager.deleteLabelsForTxn(txId = txId) + isEditing = false + hasFocusedOnce = false + editingLabel = TextFieldValue() + currentLabel = null - updateDetails() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e(TAG, "Unable to delete label", e) - val message = context.getString(R.string.label_delete_error, e.message ?: "Unknown error") - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) + updateDetails() + }.onFailure { e -> + val message = context.getString(R.string.label_delete_error, e.message ?: "Unknown error") + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, + ) + } } finally { isOperationInProgress = false } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsRows.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsRows.kt index fa9045ca9..afeeafe06 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsRows.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsRows.kt @@ -41,11 +41,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.CancellationException import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove_core.AppAlertState @@ -465,12 +465,11 @@ private fun transactionLockStateForRow( val lockState = manager.transactionLockStates[txId] LaunchedEffect(manager.id, txId) { - try { - manager.transactionLockState(txId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e(TAG, "failed to load transaction lock state", e) + val loaded = + runCatchingCancellable(TAG, "failed to load transaction lock state") { + manager.transactionLockState(txId) + }.isSuccess + if (!loaded) { manager.clearTransactionLockState(txId) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/AdvancedDetailsBottomSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/AdvancedDetailsBottomSheet.kt similarity index 99% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/AdvancedDetailsBottomSheet.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/AdvancedDetailsBottomSheet.kt index 161a4f31e..1ee9248ee 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/AdvancedDetailsBottomSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/AdvancedDetailsBottomSheet.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.Common import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlCustomAmountSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/CoinControlCustomAmountSheet.kt similarity index 97% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlCustomAmountSheet.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/CoinControlCustomAmountSheet.kt index 08135eceb..d51c47e19 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlCustomAmountSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/CoinControlCustomAmountSheet.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.Common import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -29,6 +31,7 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.flows.CoinControlFlow.displayDate import org.bitcoinppl.cove.flows.CoinControlFlow.displayName +import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove_core.SendFlowManagerAction import org.bitcoinppl.cove_core.ffiConservativeDustLimitSats @@ -69,7 +72,12 @@ fun CoinControlCustomAmountSheet( // min/max calculations val conservativeDustLimitSatsU = remember { ffiConservativeDustLimitSats() } val conservativeDustLimitSats = conservativeDustLimitSatsU.toLong() - val minSend = if (isSats) conservativeDustLimitSats.toDouble() else conservativeDustLimitSats.toDouble() / 100_000_000.0 + val minSend = + if (isSats) { + conservativeDustLimitSats.toDouble() + } else { + conservativeDustLimitSats.toDouble() / 100_000_000.0 + } val step = if (isSats) 10.0 else 0.0000001 diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowAdvancedDetailsView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowAdvancedDetailsView.kt similarity index 99% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowAdvancedDetailsView.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowAdvancedDetailsView.kt index ea8e5b90b..408472172 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowAdvancedDetailsView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowAdvancedDetailsView.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.Common import android.content.ClipData import android.content.ClipboardManager diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHeaderView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowHeaderView.kt similarity index 97% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHeaderView.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowHeaderView.kt index 2b0c36539..25d02ee8b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHeaderView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/Common/SendFlowHeaderView.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.Common import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowConfirmScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/ConfirmScreen/SendFlowConfirmScreen.kt similarity index 91% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowConfirmScreen.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/ConfirmScreen/SendFlowConfirmScreen.kt index 07acc549b..370bea0e9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowConfirmScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/ConfirmScreen/SendFlowConfirmScreen.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.ConfirmScreen import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode @@ -38,6 +40,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -46,10 +49,14 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.flows.SendFlow.Common.SendFlowAdvancedDetailsView +import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager +import org.bitcoinppl.cove.flows.SendFlow.SendState import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.ForceLightStatusBarIcons import org.bitcoinppl.cove.ui.theme.coveColors import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.KeyValueRow import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.types.ConfirmDetails @@ -383,82 +390,70 @@ private fun SummaryWidget( modifier = Modifier.fillMaxWidth(), ) { Spacer(Modifier.height(28.dp)) - KeyValueRow( - key = stringResource(R.string.label_address), + SummaryKeyValueRow( + label = stringResource(R.string.label_address), value = address, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onAddressClick), + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, valueColor = MaterialTheme.colorScheme.onSurface, - keyColor = MaterialTheme.colorScheme.onSurfaceVariant, - boldValue = true, - onClick = onAddressClick, + valueFontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(20.dp)) - KeyValueRow( - key = stringResource(R.string.label_network_fee), + SummaryKeyValueRow( + label = stringResource(R.string.label_network_fee), value = networkFee, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, valueColor = MaterialTheme.colorScheme.onSurfaceVariant, - keyColor = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(20.dp)) - KeyValueRow( - key = stringResource(R.string.label_they_will_receive), + SummaryKeyValueRow( + label = stringResource(R.string.label_they_will_receive), value = willReceive, + labelColor = MaterialTheme.colorScheme.onSurface, valueColor = MaterialTheme.colorScheme.onSurface, - boldValue = true, - boldKey = true, - keyColor = MaterialTheme.colorScheme.onSurface, + labelFontWeight = FontWeight.SemiBold, + valueFontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(20.dp)) - KeyValueRow( - key = stringResource(R.string.label_you_will_pay), + SummaryKeyValueRow( + label = stringResource(R.string.label_you_will_pay), value = willPay, + labelColor = MaterialTheme.colorScheme.onSurface, valueColor = MaterialTheme.colorScheme.onSurface, - boldValue = true, - boldKey = true, - keyColor = MaterialTheme.colorScheme.onSurface, + labelFontWeight = FontWeight.SemiBold, + valueFontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(20.dp)) } } @Composable -private fun KeyValueRow( - key: String, +@Suppress("FunctionNaming", "LongParameterList") +private fun SummaryKeyValueRow( + label: String, value: String, - keyColor: Color, + labelColor: Color, valueColor: Color, - boldValue: Boolean = false, - boldKey: Boolean = false, - onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier.fillMaxWidth(), + labelFontWeight: FontWeight = FontWeight.Normal, + valueFontWeight: FontWeight = FontWeight.Normal, ) { - val rowModifier = - if (onClick != null) { - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - } else { - Modifier.fillMaxWidth() - } - Row( - modifier = rowModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - key, - color = keyColor, - fontSize = 13.sp, - fontWeight = if (boldKey) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - Text( - value, - color = valueColor, - fontSize = 13.sp, - fontWeight = if (boldValue) FontWeight.SemiBold else FontWeight.Normal, - textAlign = TextAlign.Right, - modifier = Modifier.weight(2f), - maxLines = 4, - ) - } + KeyValueRow( + label = label, + value = value, + modifier = modifier, + labelWeight = 1f, + valueWeight = 2f, + labelStyle = TextStyle(fontSize = 13.sp, fontWeight = labelFontWeight), + valueStyle = TextStyle(fontSize = 13.sp, fontWeight = valueFontWeight), + labelColor = labelColor, + valueColor = valueColor, + valueTextAlign = TextAlign.Right, + valueMaxLines = 4, + ) } @Composable diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareDetailsSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareDetailsSheet.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareDetailsSheet.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareDetailsSheet.kt index 2d3110a78..5925ecfa0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareDetailsSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareDetailsSheet.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareImportRoute.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareImportRoute.kt similarity index 97% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareImportRoute.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareImportRoute.kt index aa91a5f33..518bf2325 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareImportRoute.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareImportRoute.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import android.content.Context import android.net.Uri diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareScreen.kt similarity index 99% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareScreen.kt index 89228edae..d83872a2d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareScreen.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import android.content.ClipData import android.content.ClipboardManager diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSections.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSections.kt similarity index 99% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSections.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSections.kt index f4e6da913..6620a72f7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSections.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSections.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSheets.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSheets.kt similarity index 93% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSheets.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSheets.kt index ca2a14045..6105db2bb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSheets.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSheets.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api @@ -10,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.flows.SendFlow.Common.SendFlowAdvancedDetailsView import org.bitcoinppl.cove.views.QrExportView import org.bitcoinppl.cove_core.types.ConfirmDetails diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSigning.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSigning.kt similarity index 99% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSigning.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSigning.kt index d01830907..5b4e193fe 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareSigning.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/HardwareScreen/SendFlowHardwareSigning.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.HardwareScreen import android.content.ClipboardManager import android.content.Context diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowContainer.kt index 1581c0423..d20a21310 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowContainer.kt @@ -27,6 +27,10 @@ import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.flows.SendFlow.ConfirmScreen.SendFlowConfirmScreen +import org.bitcoinppl.cove.flows.SendFlow.HardwareScreen.SendFlowHardwareScreen +import org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen.CoinControlSetAmountScreen +import org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen.SendFlowSetAmountScreen import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.AppAlertState diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/CoinControlSetAmountScreen.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlSetAmountScreen.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/CoinControlSetAmountScreen.kt index aa0d469af..926bfd671 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/CoinControlSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/CoinControlSetAmountScreen.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -48,6 +50,9 @@ import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.R import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.flows.SendFlow.Common.CoinControlCustomAmountSheet +import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager +import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.ForceLightStatusBarIcons diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAddressView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAddressView.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAddressView.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAddressView.kt index 88a604d3f..40bf58f8c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAddressView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAddressView.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAmountView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAmountView.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAmountView.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAmountView.kt index 145ee7130..450714264 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/EnterAmountView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/EnterAmountView.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/NetworkFeeBottomSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/NetworkFeeBottomSheet.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/NetworkFeeBottomSheet.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/NetworkFeeBottomSheet.kt index 691c7bb40..7664b6fea 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/NetworkFeeBottomSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/NetworkFeeBottomSheet.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -18,6 +20,7 @@ import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.coveColors import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.types.FeeRate import java.util.Locale enum class FeePriority( @@ -179,7 +182,7 @@ private fun NetworkFeeBottomSheetPreview() { priority = FeePriority.CUSTOM, timeEstimate = "10 minutes", feeAmount = "1,128 sats", - feeRate = String.format(Locale.US, "%.2f sats/vbyte", feeRate), + feeRate = FeeRate.fromSatPerVb(feeRate).satsPerVbyteString(), dollarAmount = "≈ $1.28", ) selectedPriority = FeePriority.CUSTOM diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/SendFlowSetAmountScreen.kt similarity index 98% rename from android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowSetAmountScreen.kt rename to android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/SendFlowSetAmountScreen.kt index ed4e97be9..badf73cee 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SetAmountScreen/SendFlowSetAmountScreen.kt @@ -1,4 +1,6 @@ -package org.bitcoinppl.cove.flows.SendFlow +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SendFlow.SetAmountScreen import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -69,6 +71,9 @@ import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.flows.SendFlow.Common.SendFlowHeaderView +import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager +import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.ForceLightStatusBarIcons import org.bitcoinppl.cove.ui.theme.coveColors diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupExportScreen.kt index c2783eefa..6486a91cf 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupExportScreen.kt @@ -70,6 +70,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove_core.BackupException import org.bitcoinppl.cove_core.BackupManager import org.bitcoinppl.cove_core.BackupResult @@ -126,28 +127,30 @@ fun BackupExportScreen( } scope.launch { try { - withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(uri)?.use { output -> - output.write(result.data) - output.flush() - } ?: throw java.io.IOException("Failed to open output stream") - } - isExporting = false - pendingResult = null + runCatchingCancellable("BackupExport", "Failed to save backup") { + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(result.data) + output.flush() + } ?: throw java.io.IOException("Failed to open output stream") + } + }.onSuccess { + isExporting = false + pendingResult = null - if (result.warnings.isNotEmpty()) { - warningMessage = "Some data could not be exported:\n" + result.warnings.joinToString("\n") - } else { - handleDismiss() + if (result.warnings.isNotEmpty()) { + warningMessage = "Some data could not be exported:\n" + result.warnings.joinToString("\n") + } else { + handleDismiss() + } + }.onFailure { e -> + isExporting = false + pendingResult = null + errorMessage = "Failed to save backup: ${e.message}" } } catch (e: CancellationException) { isExporting = false throw e - } catch (e: Exception) { - android.util.Log.e("BackupExport", "Failed to save backup", e) - isExporting = false - pendingResult = null - errorMessage = "Failed to save backup: ${e.message}" } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupImportScreen.kt index 5147dd7f7..0faf3b506 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupImportScreen.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove_core.AppAction import org.bitcoinppl.cove_core.BackupException import org.bitcoinppl.cove_core.BackupImportReport @@ -112,8 +113,8 @@ fun BackupImportScreen( ) { uri -> uri?.let { scope.launch { - try { - val (bytes, name) = withContext(Dispatchers.IO) { + runCatchingCancellable("BackupImport", "Failed to read file") { + withContext(Dispatchers.IO) { val maxSize = 50_000_000 val docFile = DocumentFile.fromSingleUri(context, uri) val fileSize = docFile?.length() ?: 0 @@ -139,16 +140,13 @@ fun BackupImportScreen( bytes to (DocumentFile.fromSingleUri(context, uri)?.name ?: "backup file") } - + }.onSuccess { (bytes, name) -> fileData = bytes fileName = name - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("BackupImport", "Failed to read file", e) + }.onFailure { e -> fileData = null fileName = null - errorMessage = backupErrorMessage(e) + errorMessage = backupErrorMessage(e as Exception) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupVerifyScreen.kt index 9e6ba4259..eb7a7df25 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/BackupVerifyScreen.kt @@ -76,6 +76,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.bitcoinppl.cove.runCatchingCancellable import org.bitcoinppl.cove_core.BackupException import org.bitcoinppl.cove_core.BackupManager import org.bitcoinppl.cove_core.BackupVerifyReport @@ -121,8 +122,8 @@ fun BackupVerifyScreen( ) { uri -> uri?.let { scope.launch { - try { - val (bytes, name) = withContext(Dispatchers.IO) { + runCatchingCancellable("BackupVerify", "Failed to read file") { + withContext(Dispatchers.IO) { val maxSize = 50_000_000 val docFile = DocumentFile.fromSingleUri(context, uri) val fileSize = docFile?.length() ?: 0 @@ -147,16 +148,13 @@ fun BackupVerifyScreen( bytes to (DocumentFile.fromSingleUri(context, uri)?.name ?: "backup file") } - + }.onSuccess { (bytes, name) -> fileData = bytes fileName = name - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e("BackupVerify", "Failed to read file", e) + }.onFailure { e -> fileData = null fileName = null - errorMessage = backupVerifyErrorMessage(e) + errorMessage = backupVerifyErrorMessage(e as Exception) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt index cc04eec9b..50ea633bc 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt @@ -2,30 +2,20 @@ package org.bitcoinppl.cove.flows.SettingsFlow import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.Log @@ -33,7 +23,6 @@ import org.bitcoinppl.cove.findFragmentActivity import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection import org.bitcoinppl.cove.views.MaterialSettingsItem -import org.bitcoinppl.cove.views.NumberPadPinView import org.bitcoinppl.cove.views.SectionHeader import org.bitcoinppl.cove_core.AuthManagerAction import org.bitcoinppl.cove_core.AuthManagerException @@ -59,60 +48,20 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { // sheet and alert state (using Rust enums) var sheetState: SecuritySheetState by remember { mutableStateOf(SecuritySheetState.NONE) } var alertState: SecurityAlertState? by remember { mutableStateOf(null) } - - // local state for decoy mode (settings changes only affect UI, not persisted) - var decoyModePinEnabled by remember { mutableStateOf(true) } - var decoyModeFaceIdEnabled by remember { mutableStateOf(false) } - var decoyModeWipeDataPinEnabled by remember { mutableStateOf(false) } - var decoyModeDecoyPinEnabled by remember { mutableStateOf(false) } - - // track which action triggered decoy mode update (for local state updates) - var lastDecoyAction: SecuritySettingsAction? by remember { mutableStateOf(null) } + val decoyModeState = remember { SecurityDecoyModeState() } // computed toggle values - val isBiometricEnabled = - if (auth.isInDecoyMode()) { - decoyModeFaceIdEnabled - } else { - auth.type == AuthType.BOTH || auth.type == AuthType.BIOMETRIC - } - - val isPinEnabled = - if (auth.isInDecoyMode()) { - decoyModePinEnabled - } else { - auth.type == AuthType.BOTH || auth.type == AuthType.PIN - } - - val isWipeDataPinEnabled = - if (auth.isInDecoyMode()) { - decoyModeWipeDataPinEnabled - } else { - auth.isWipeDataPinEnabled - } - - val isDecoyPinEnabled = - if (auth.isInDecoyMode()) { - decoyModeDecoyPinEnabled - } else { - auth.isDecoyPinEnabled - } + val isBiometricEnabled = decoyModeState.isBiometricEnabled(auth) + val isPinEnabled = decoyModeState.isPinEnabled(auth) + val isWipeDataPinEnabled = decoyModeState.isWipeDataPinEnabled(auth) + val isDecoyPinEnabled = decoyModeState.isDecoyPinEnabled(auth) // handle security result from Rust validation fun handleSecurityResult(result: SecuritySettingsResult, action: SecuritySettingsAction) { when (result) { is SecuritySettingsResult.ProceedToSheet -> sheetState = result.v1 is SecuritySettingsResult.ShowAlert -> alertState = result.v1 - is SecuritySettingsResult.DecoyModeLocalUpdate -> { - lastDecoyAction = action - when (action) { - is SecuritySettingsAction.ToggleBiometric -> decoyModeFaceIdEnabled = action.enable - is SecuritySettingsAction.TogglePin -> decoyModePinEnabled = action.enable - is SecuritySettingsAction.ToggleWipeDataPin -> decoyModeWipeDataPinEnabled = action.enable - is SecuritySettingsAction.ToggleDecoyPin -> decoyModeDecoyPinEnabled = action.enable - is SecuritySettingsAction.ChangePin -> {} - } - } + is SecuritySettingsResult.DecoyModeLocalUpdate -> decoyModeState.apply(action) } } @@ -144,7 +93,7 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { // setter functions fun setPin(pin: String) { if (auth.isInDecoyMode()) { - decoyModePinEnabled = true + decoyModeState.enablePin() sheetState = SecuritySheetState.NONE return } @@ -155,7 +104,7 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { fun setWipeDataPin(pin: String) { sheetState = SecuritySheetState.NONE if (auth.isInDecoyMode()) { - decoyModeWipeDataPinEnabled = true + decoyModeState.enableWipeDataPin() return } @@ -169,7 +118,7 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { fun setDecoyPin(pin: String) { sheetState = SecuritySheetState.NONE if (auth.isInDecoyMode()) { - decoyModeDecoyPinEnabled = true + decoyModeState.enableDecoyPin() return } @@ -335,14 +284,17 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { alertState?.let { state -> SecurityAlertDialog( state = state, - onDismiss = { alertState = null }, - onConfirmSheet = { nextSheet -> - alertState = null - sheetState = nextSheet - }, - onConfirmAlert = { nextAlert -> - alertState = nextAlert - }, + callbacks = + SecurityAlertDialogCallbacks( + onDismiss = { alertState = null }, + onConfirmSheet = { nextSheet -> + alertState = null + sheetState = nextSheet + }, + onConfirmAlert = { nextAlert -> + alertState = nextAlert + }, + ), auth = auth, app = app, ) @@ -359,441 +311,19 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { if (showSheetDialog) { SecuritySheetDialog( state = sheetState, - onDismiss = { sheetState = SecuritySheetState.NONE }, - onNextState = { nextState -> sheetState = nextState }, - onSetPin = ::setPin, - onSetWipeDataPin = ::setWipeDataPin, - onSetDecoyPin = ::setDecoyPin, + callbacks = + SecuritySheetDialogCallbacks( + onDismiss = { sheetState = SecuritySheetState.NONE }, + onNextState = { nextState -> sheetState = nextState }, + pinCallbacks = + SecuritySheetPinCallbacks( + onSetPin = ::setPin, + onSetWipeDataPin = ::setWipeDataPin, + onSetDecoyPin = ::setDecoyPin, + ), + onAlertState = { alertState = it }, + ), auth = auth, - onAlertState = { alertState = it }, ) } } - -@Composable -private fun SecurityAlertDialog( - state: SecurityAlertState, - onDismiss: () -> Unit, - onConfirmSheet: (SecuritySheetState) -> Unit, - onConfirmAlert: (SecurityAlertState) -> Unit, - auth: org.bitcoinppl.cove.AuthManager, - app: org.bitcoinppl.cove.AppManager, -) { - when (state) { - is SecurityAlertState.UnverifiedWallets -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Can't Enable Wipe Data PIN") }, - text = { - Text( - "You have wallets that have not been backed up. Please back up your wallets before " + - "enabling the Wipe Data PIN. If you wipe the data without having a backup of your " + - "wallet, you will lose the bitcoin in that wallet.", - ) - }, - confirmButton = { - TextButton( - onClick = { - try { - app.selectWalletOrThrow(state.walletId) - } catch (e: Exception) { - Log.e("SecuritySection", "Failed to select wallet ${state.walletId}", e) - } - onDismiss() - }, - ) { - Text("Go To Wallet") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.ConfirmEnableWipeMePin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Are you sure?") }, - text = { - Text( - "Enabling the Wipe Data PIN will let you choose a PIN that if entered will wipe all " + - "Cove wallet data on this device.\n\nIf you wipe the data without having a backup " + - "of your wallet, you will lose the bitcoin in that wallet.\n\nPlease make sure you " + - "have a backup of your wallet before enabling this.", - ) - }, - confirmButton = { - TextButton(onClick = { onConfirmSheet(SecuritySheetState.ENABLE_WIPE_DATA_PIN) }) { - Text("Yes, Enable Wipe Data PIN") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.ConfirmDecoyPin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Are you sure?") }, - text = { - Text( - "Enabling Decoy PIN will let you choose a PIN that if entered, will show you a different " + - "set of wallets.\n\nThese wallets will only be accessible by entering the decoy PIN " + - "instead of your regular PIN.\n\nTo access your regular wallets, you will have to close " + - "the app, start it again and enter your regular PIN.", - ) - }, - confirmButton = { - TextButton(onClick = { onConfirmSheet(SecuritySheetState.ENABLE_DECOY_PIN) }) { - Text("Yes, Enable Decoy PIN") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.NotePinRequired -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("PIN is required") }, - text = { Text("Setting a PIN is required to have a wipe data PIN or decoy PIN.") }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("OK") - } - }, - ) - } - - SecurityAlertState.NoteFaceIdDisablingForWipeMePin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Disable Biometric Unlock?") }, - text = { - Text( - "Enabling this trick PIN will disable biometric unlock for Cove.\n\nGoing forward, you " + - "will have to use your PIN to unlock Cove.", - ) - }, - confirmButton = { - TextButton( - onClick = { - auth.dispatch(AuthManagerAction.DisableBiometric) - onConfirmAlert(SecurityAlertState.ConfirmEnableWipeMePin) - }, - ) { - Text("Disable Biometric") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.NoteFaceIdDisablingForDecoyPin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Disable Biometric Unlock?") }, - text = { - Text( - "Enabling this trick PIN will disable biometric unlock for Cove.\n\nGoing forward, you " + - "will have to use your PIN to unlock Cove.", - ) - }, - confirmButton = { - TextButton( - onClick = { - auth.dispatch(AuthManagerAction.DisableBiometric) - onConfirmAlert(SecurityAlertState.ConfirmDecoyPin) - }, - ) { - Text("Disable Biometric") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.NoteNoFaceIdWhenTrickPins -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Can't do that") }, - text = { - Text( - "You can't have Decoy PIN & Wipe Data Pin enabled and biometric active at the same time.\n\n" + - "Do you want to disable both of these trick PINs and enable biometric?", - ) - }, - confirmButton = { - TextButton(onClick = { onConfirmSheet(SecuritySheetState.REMOVE_ALL_TRICK_PINS) }) { - Text("Yes, Disable trick PINs") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Can't do that") }, - text = { Text("You can't have both Wipe Data PIN and biometric active at the same time.") }, - confirmButton = { - TextButton( - onClick = { - // if no decoy PIN, we can enable biometric after removing wipe data PIN - val nextSheet = - if (!auth.isDecoyPinEnabled) { - SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC - } else { - SecuritySheetState.REMOVE_WIPE_DATA_PIN - } - onConfirmSheet(nextSheet) - }, - ) { - Text("Disable Wipe Data PIN") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Can't do that") }, - text = { Text("You can't have both Decoy PIN and biometric active at the same time.") }, - confirmButton = { - TextButton( - onClick = { - // if no wipe data PIN, we can enable biometric after removing decoy PIN - val nextSheet = - if (!auth.isWipeDataPinEnabled) { - SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC - } else { - SecuritySheetState.REMOVE_DECOY_PIN - } - onConfirmSheet(nextSheet) - }, - ) { - Text("Disable Decoy PIN") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } - - is SecurityAlertState.ExtraSetPinError -> { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Something went wrong!") }, - text = { Text(state.message) }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("OK") - } - }, - ) - } - } -} - -@Composable -private fun SecuritySheetDialog( - state: SecuritySheetState, - onDismiss: () -> Unit, - onNextState: (SecuritySheetState) -> Unit, - onSetPin: (String) -> Unit, - onSetWipeDataPin: (String) -> Unit, - onSetDecoyPin: (String) -> Unit, - auth: org.bitcoinppl.cove.AuthManager, - onAlertState: (SecurityAlertState) -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - when (state) { - SecuritySheetState.NEW_PIN -> { - NewPinView( - onComplete = onSetPin, - backAction = onDismiss, - ) - } - - SecuritySheetState.REMOVE_PIN -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> - if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) - }, - backAction = onDismiss, - onUnlock = { - if (auth.isInDecoyMode()) { - onDismiss() - return@NumberPadPinView - } - auth.dispatch(AuthManagerAction.DisablePin) - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onDismiss() - }, - ) - } - - SecuritySheetState.CHANGE_PIN -> { - ChangePinView( - isPinCorrect = { pin -> - if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) - }, - backAction = onDismiss, - onComplete = { pin -> - if (auth.isInDecoyMode()) { - onDismiss() - return@ChangePinView - } - - // use Rust validation for new PIN - val error = auth.validateNewPin(pin) - if (error != null) { - onDismiss() - onAlertState(SecurityAlertState.ExtraSetPinError(error)) - return@ChangePinView - } - - onSetPin(pin) - }, - ) - } - - SecuritySheetState.DISABLE_BIOMETRIC -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableBiometric) - onDismiss() - }, - ) - } - - SecuritySheetState.REMOVE_WIPE_DATA_PIN -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - if (auth.isInDecoyMode()) { - onDismiss() - return@NumberPadPinView - } - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onDismiss() - }, - ) - } - - SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onNextState(SecuritySheetState.ENABLE_BIOMETRIC) - }, - ) - } - - SecuritySheetState.REMOVE_DECOY_PIN -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableDecoyPin) - onDismiss() - }, - ) - } - - SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableDecoyPin) - onNextState(SecuritySheetState.ENABLE_BIOMETRIC) - }, - ) - } - - SecuritySheetState.REMOVE_ALL_TRICK_PINS -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableDecoyPin) - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onNextState(SecuritySheetState.ENABLE_BIOMETRIC) - }, - ) - } - - SecuritySheetState.ENABLE_WIPE_DATA_PIN -> { - WipeDataPinView( - onComplete = onSetWipeDataPin, - backAction = onDismiss, - ) - } - - SecuritySheetState.ENABLE_DECOY_PIN -> { - DecoyPinView( - onComplete = onSetDecoyPin, - backAction = onDismiss, - ) - } - - else -> {} - } - } - } -} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityAlertDialog.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityAlertDialog.kt new file mode 100644 index 000000000..fc0dc61ac --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityAlertDialog.kt @@ -0,0 +1,310 @@ +@file:Suppress("FunctionNaming", "PackageNaming", "TooGenericExceptionCaught") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.AuthManager +import org.bitcoinppl.cove.Log +import org.bitcoinppl.cove_core.AuthManagerAction +import org.bitcoinppl.cove_core.SecurityAlertState +import org.bitcoinppl.cove_core.SecuritySheetState + +@Composable +internal fun SecurityAlertDialog( + state: SecurityAlertState, + callbacks: SecurityAlertDialogCallbacks, + auth: AuthManager, + app: AppManager, +) { + when (state) { + is SecurityAlertState.UnverifiedWallets -> UnverifiedWalletsAlert(state, callbacks, app) + SecurityAlertState.ConfirmEnableWipeMePin -> ConfirmEnableWipeDataPinAlert(callbacks) + SecurityAlertState.ConfirmDecoyPin -> ConfirmDecoyPinAlert(callbacks) + SecurityAlertState.NotePinRequired -> NotePinRequiredAlert(callbacks) + SecurityAlertState.NoteFaceIdDisablingForWipeMePin -> FaceIdDisablingForWipeDataPinAlert(callbacks, auth) + SecurityAlertState.NoteFaceIdDisablingForDecoyPin -> FaceIdDisablingForDecoyPinAlert(callbacks, auth) + SecurityAlertState.NoteNoFaceIdWhenTrickPins -> NoteNoFaceIdWhenTrickPinsAlert(callbacks) + SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> NoteNoFaceIdWhenWipeDataPinAlert(callbacks, auth) + SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> NoteNoFaceIdWhenDecoyPinAlert(callbacks, auth) + is SecurityAlertState.ExtraSetPinError -> ExtraSetPinErrorAlert(state, callbacks) + } +} + +@Composable +private fun UnverifiedWalletsAlert( + state: SecurityAlertState.UnverifiedWallets, + callbacks: SecurityAlertDialogCallbacks, + app: AppManager, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Can't Enable Wipe Data PIN") }, + text = { + Text( + "You have wallets that have not been backed up. Please back up your wallets before " + + "enabling the Wipe Data PIN. If you wipe the data without having a backup of your " + + "wallet, you will lose the bitcoin in that wallet.", + ) + }, + confirmButton = { + TextButton( + onClick = { + try { + app.selectWalletOrThrow(state.walletId) + } catch (e: Exception) { + Log.e("SecuritySection", "Failed to select wallet ${state.walletId}", e) + } + callbacks.onDismiss() + }, + ) { + Text("Go To Wallet") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun ConfirmEnableWipeDataPinAlert(callbacks: SecurityAlertDialogCallbacks) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Are you sure?") }, + text = { + Text( + "Enabling the Wipe Data PIN will let you choose a PIN that if entered will wipe all " + + "Cove wallet data on this device.\n\nIf you wipe the data without having a backup " + + "of your wallet, you will lose the bitcoin in that wallet.\n\nPlease make sure you " + + "have a backup of your wallet before enabling this.", + ) + }, + confirmButton = { + TextButton(onClick = { callbacks.onConfirmSheet(SecuritySheetState.ENABLE_WIPE_DATA_PIN) }) { + Text("Yes, Enable Wipe Data PIN") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun ConfirmDecoyPinAlert(callbacks: SecurityAlertDialogCallbacks) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Are you sure?") }, + text = { + Text( + "Enabling Decoy PIN will let you choose a PIN that if entered, will show you a different " + + "set of wallets.\n\nThese wallets will only be accessible by entering the decoy PIN " + + "instead of your regular PIN.\n\nTo access your regular wallets, you will have to close " + + "the app, start it again and enter your regular PIN.", + ) + }, + confirmButton = { + TextButton(onClick = { callbacks.onConfirmSheet(SecuritySheetState.ENABLE_DECOY_PIN) }) { + Text("Yes, Enable Decoy PIN") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun NotePinRequiredAlert(callbacks: SecurityAlertDialogCallbacks) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("PIN is required") }, + text = { Text("Setting a PIN is required to have a wipe data PIN or decoy PIN.") }, + confirmButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("OK") + } + }, + ) +} + +@Composable +private fun FaceIdDisablingForWipeDataPinAlert( + callbacks: SecurityAlertDialogCallbacks, + auth: AuthManager, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Disable Biometric Unlock?") }, + text = { + Text( + "Enabling this trick PIN will disable biometric unlock for Cove.\n\nGoing forward, you " + + "will have to use your PIN to unlock Cove.", + ) + }, + confirmButton = { + TextButton( + onClick = { + auth.dispatch(AuthManagerAction.DisableBiometric) + callbacks.onConfirmAlert(SecurityAlertState.ConfirmEnableWipeMePin) + }, + ) { + Text("Disable Biometric") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun FaceIdDisablingForDecoyPinAlert( + callbacks: SecurityAlertDialogCallbacks, + auth: AuthManager, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Disable Biometric Unlock?") }, + text = { + Text( + "Enabling this trick PIN will disable biometric unlock for Cove.\n\nGoing forward, you " + + "will have to use your PIN to unlock Cove.", + ) + }, + confirmButton = { + TextButton( + onClick = { + auth.dispatch(AuthManagerAction.DisableBiometric) + callbacks.onConfirmAlert(SecurityAlertState.ConfirmDecoyPin) + }, + ) { + Text("Disable Biometric") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun NoteNoFaceIdWhenTrickPinsAlert(callbacks: SecurityAlertDialogCallbacks) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Can't do that") }, + text = { + Text( + "You can't have Decoy PIN & Wipe Data Pin enabled and biometric active at the same time.\n\n" + + "Do you want to disable both of these trick PINs and enable biometric?", + ) + }, + confirmButton = { + TextButton(onClick = { callbacks.onConfirmSheet(SecuritySheetState.REMOVE_ALL_TRICK_PINS) }) { + Text("Yes, Disable trick PINs") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun NoteNoFaceIdWhenWipeDataPinAlert( + callbacks: SecurityAlertDialogCallbacks, + auth: AuthManager, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Can't do that") }, + text = { Text("You can't have both Wipe Data PIN and biometric active at the same time.") }, + confirmButton = { + TextButton( + onClick = { + // if no decoy PIN, we can enable biometric after removing wipe data PIN + val nextSheet = + if (!auth.isDecoyPinEnabled) { + SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC + } else { + SecuritySheetState.REMOVE_WIPE_DATA_PIN + } + callbacks.onConfirmSheet(nextSheet) + }, + ) { + Text("Disable Wipe Data PIN") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun NoteNoFaceIdWhenDecoyPinAlert( + callbacks: SecurityAlertDialogCallbacks, + auth: AuthManager, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Can't do that") }, + text = { Text("You can't have both Decoy PIN and biometric active at the same time.") }, + confirmButton = { + TextButton( + onClick = { + // if no wipe data PIN, we can enable biometric after removing decoy PIN + val nextSheet = + if (!auth.isWipeDataPinEnabled) { + SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC + } else { + SecuritySheetState.REMOVE_DECOY_PIN + } + callbacks.onConfirmSheet(nextSheet) + }, + ) { + Text("Disable Decoy PIN") + } + }, + dismissButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun ExtraSetPinErrorAlert( + state: SecurityAlertState.ExtraSetPinError, + callbacks: SecurityAlertDialogCallbacks, +) { + AlertDialog( + onDismissRequest = callbacks.onDismiss, + title = { Text("Something went wrong!") }, + text = { Text(state.message) }, + confirmButton = { + TextButton(onClick = callbacks.onDismiss) { + Text("OK") + } + }, + ) +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDecoyModeState.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDecoyModeState.kt new file mode 100644 index 000000000..e03f7fb6b --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDecoyModeState.kt @@ -0,0 +1,79 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.AuthManager +import org.bitcoinppl.cove_core.AuthType +import org.bitcoinppl.cove_core.SecuritySettingsAction + +internal class SecurityDecoyModeState { + var pinEnabled by mutableStateOf(true) + private set + + var faceIdEnabled by mutableStateOf(false) + private set + + var wipeDataPinEnabled by mutableStateOf(false) + private set + + var decoyPinEnabled by mutableStateOf(false) + private set + + var lastAction: SecuritySettingsAction? by mutableStateOf(null) + private set + + fun isBiometricEnabled(auth: AuthManager): Boolean = + if (auth.isInDecoyMode()) { + faceIdEnabled + } else { + auth.type == AuthType.BOTH || auth.type == AuthType.BIOMETRIC + } + + fun isPinEnabled(auth: AuthManager): Boolean = + if (auth.isInDecoyMode()) { + pinEnabled + } else { + auth.type == AuthType.BOTH || auth.type == AuthType.PIN + } + + fun isWipeDataPinEnabled(auth: AuthManager): Boolean = + if (auth.isInDecoyMode()) { + wipeDataPinEnabled + } else { + auth.isWipeDataPinEnabled + } + + fun isDecoyPinEnabled(auth: AuthManager): Boolean = + if (auth.isInDecoyMode()) { + decoyPinEnabled + } else { + auth.isDecoyPinEnabled + } + + fun apply(action: SecuritySettingsAction) { + lastAction = action + + when (action) { + is SecuritySettingsAction.ToggleBiometric -> faceIdEnabled = action.enable + is SecuritySettingsAction.TogglePin -> pinEnabled = action.enable + is SecuritySettingsAction.ToggleWipeDataPin -> wipeDataPinEnabled = action.enable + is SecuritySettingsAction.ToggleDecoyPin -> decoyPinEnabled = action.enable + is SecuritySettingsAction.ChangePin -> {} + } + } + + fun enablePin() { + pinEnabled = true + } + + fun enableWipeDataPin() { + wipeDataPinEnabled = true + } + + fun enableDecoyPin() { + decoyPinEnabled = true + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDialogCallbacks.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDialogCallbacks.kt new file mode 100644 index 000000000..a0608d814 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecurityDialogCallbacks.kt @@ -0,0 +1,25 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import org.bitcoinppl.cove_core.SecurityAlertState +import org.bitcoinppl.cove_core.SecuritySheetState + +internal data class SecurityAlertDialogCallbacks( + val onDismiss: () -> Unit, + val onConfirmSheet: (SecuritySheetState) -> Unit, + val onConfirmAlert: (SecurityAlertState) -> Unit, +) + +internal class SecuritySheetPinCallbacks( + val onSetPin: (String) -> Unit, + val onSetWipeDataPin: (String) -> Unit, + val onSetDecoyPin: (String) -> Unit, +) + +internal class SecuritySheetDialogCallbacks( + val onDismiss: () -> Unit, + val onNextState: (SecuritySheetState) -> Unit, + val pinCallbacks: SecuritySheetPinCallbacks, + val onAlertState: (SecurityAlertState) -> Unit, +) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecuritySheetDialog.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecuritySheetDialog.kt new file mode 100644 index 000000000..3d6c19d26 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SecuritySheetDialog.kt @@ -0,0 +1,225 @@ +@file:Suppress("FunctionNaming", "PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import org.bitcoinppl.cove.AuthManager +import org.bitcoinppl.cove.views.NumberPadPinView +import org.bitcoinppl.cove_core.AuthManagerAction +import org.bitcoinppl.cove_core.SecurityAlertState +import org.bitcoinppl.cove_core.SecuritySheetState + +@Composable +internal fun SecuritySheetDialog( + state: SecuritySheetState, + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + Dialog( + onDismissRequest = callbacks.onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + when (state) { + SecuritySheetState.NEW_PIN -> NewPinView( + onComplete = callbacks.pinCallbacks.onSetPin, + backAction = callbacks.onDismiss, + ) + SecuritySheetState.REMOVE_PIN -> RemovePinSheet(callbacks, auth) + SecuritySheetState.CHANGE_PIN -> ChangePinSheet(callbacks, auth) + SecuritySheetState.DISABLE_BIOMETRIC -> CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableBiometric) + callbacks.onDismiss() + }, + ) + SecuritySheetState.REMOVE_WIPE_DATA_PIN -> RemoveWipeDataPinSheet(callbacks, auth) + SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC -> { + RemoveWipeDataPinThenEnableBiometricSheet(callbacks, auth) + } + SecuritySheetState.REMOVE_DECOY_PIN -> RemoveDecoyPinSheet(callbacks, auth) + SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC -> { + RemoveDecoyPinThenEnableBiometricSheet(callbacks, auth) + } + SecuritySheetState.REMOVE_ALL_TRICK_PINS -> RemoveAllTrickPinsSheet(callbacks, auth) + SecuritySheetState.ENABLE_WIPE_DATA_PIN -> WipeDataPinView( + onComplete = callbacks.pinCallbacks.onSetWipeDataPin, + backAction = callbacks.onDismiss, + ) + SecuritySheetState.ENABLE_DECOY_PIN -> DecoyPinView( + onComplete = callbacks.pinCallbacks.onSetDecoyPin, + backAction = callbacks.onDismiss, + ) + else -> {} + } + } + } +} + +@Composable +private fun RemovePinSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkActivePin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + callbacks.onDismiss() + } else { + auth.dispatch(AuthManagerAction.DisablePin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + callbacks.onDismiss() + } + }, + ) +} + +@Composable +private fun ChangePinSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + ChangePinView( + isPinCorrect = { pin -> auth.checkActivePin(pin) }, + backAction = callbacks.onDismiss, + onComplete = { pin -> completePinChange(pin, callbacks, auth) }, + ) +} + +@Composable +private fun RemoveWipeDataPinSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + callbacks.onDismiss() + } else { + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + callbacks.onDismiss() + } + }, + ) +} + +@Composable +private fun RemoveWipeDataPinThenEnableBiometricSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + callbacks.onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) +} + +@Composable +private fun RemoveDecoyPinSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + callbacks.onDismiss() + }, + ) +} + +@Composable +private fun RemoveDecoyPinThenEnableBiometricSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + callbacks.onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) +} + +@Composable +private fun RemoveAllTrickPinsSheet( + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + CurrentPinSheet( + isPinCorrect = { pin -> auth.checkPin(pin) }, + backAction = callbacks.onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + callbacks.onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) +} + +@Composable +private fun CurrentPinSheet( + isPinCorrect: (String) -> Boolean, + backAction: () -> Unit, + onUnlock: (String) -> Unit, +) { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = isPinCorrect, + backAction = backAction, + onUnlock = onUnlock, + ) +} + +private fun completePinChange( + pin: String, + callbacks: SecuritySheetDialogCallbacks, + auth: AuthManager, +) { + if (auth.isInDecoyMode()) { + callbacks.onDismiss() + return + } + + // use Rust validation for new PIN + val error = auth.validateNewPin(pin) + if (error != null) { + callbacks.onDismiss() + callbacks.onAlertState(SecurityAlertState.ExtraSetPinError(error)) + return + } + + callbacks.pinCallbacks.onSetPin(pin) +} + +private fun AuthManager.checkActivePin(pin: String): Boolean = + if (isInDecoyMode()) { + checkDecoyPin(pin) + } else { + checkPin(pin) + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/WalletSettingsScreen.kt index edf985cbd..f3aa0b222 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/WalletSettingsScreen.kt @@ -70,8 +70,6 @@ import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType import org.bitcoinppl.cove_core.defaultWalletColors -import java.math.BigInteger -import java.text.NumberFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -418,10 +416,7 @@ fun WalletSettingsScreen( private fun WalletBirthday.displayValue(): String = when (this) { - is WalletBirthday.BlockHeight -> { - val height = NumberFormat.getIntegerInstance(Locale.getDefault()).format(BigInteger(v1.toString())) - "Block $height" - } + is WalletBirthday.BlockHeight -> "Block ${blockHeightFmt()}" is WalletBirthday.Timestamp -> { val date = Date(v1.toLong() * 1000) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sheets/CustomFeeRateSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/sheets/CustomFeeRateSheet.kt index 0b05b0b24..9a1edb6c0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sheets/CustomFeeRateSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sheets/CustomFeeRateSheet.kt @@ -61,6 +61,53 @@ internal fun isTooHighCustomFeeError(error: SendFlowException): Boolean = else -> false } +private fun estimatedCustomFeeOption( + feeRate: Float, + feeSpeed: FeeSpeed, + selectedOption: FeeRateOptionWithTotalFee, +): FeeRateOptionWithTotalFee? { + val selectedFee = selectedOption.totalFee()?.asSats()?.toDouble() + val selectedRate = selectedOption.satPerVb().toDouble() + val estimatedFee = + if (selectedFee != null && selectedRate > 0) { + (feeRate.toDouble() / selectedRate) * selectedFee + } else { + null + } + + return estimatedFee?.let { + FeeRateOptionWithTotalFee( + feeSpeed = feeSpeed, + feeRate = FeeRate.fromSatPerVb(feeRate), + totalFee = Amount.fromSat(it.toULong()), + ) + } +} + +private fun customFeeTooHighAlert(): TaggedItem = + TaggedItem( + SendFlowAlertState.General( + title = "Fee too high!", + message = "The fee rate you entered is too high, we automatically selected a lower fee", + ), + ) + +private suspend fun handleTooHighCustomFee( + feeRate: Float, + presenter: SendFlowPresenter, + onDismiss: () -> Unit, +) { + withContext(Dispatchers.Main) { + presenter.erroredFeeRate = feeRate + + if (presenter.lastWorkingFeeRate != null) { + onDismiss() + delay(CustomFeeRateConstants.ALERT_DELAY_MS) + presenter.alertState = customFeeTooHighAlert() + } + } +} + /** custom fee rate sheet - allows user to set custom sats/vbyte with slider */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,7 +146,10 @@ fun CustomFeeRateSheet( val maxFeeRate = remember(updatedFeeOptions, presenter.erroredFeeRate) { val fast3 = updatedFeeOptions.fast().satPerVb() * 3 - val computed = presenter.erroredFeeRate?.let { minOf(it + CustomFeeRateConstants.FEE_RATE_EPSILON, fast3) } ?: fast3 + val computed = + presenter.erroredFeeRate + ?.let { minOf(it + CustomFeeRateConstants.FEE_RATE_EPSILON, fast3) } + ?: fast3 max(1f, computed) } @@ -110,21 +160,15 @@ fun CustomFeeRateSheet( // if address is not validated yet, estimate fee based on selected option's fee if (sendFlowManager.address == null) { - val selectedFee = selectedOption.totalFee()?.asSats()?.toDouble() ?: return - val selectedRate = selectedOption.satPerVb().toDouble() - if (selectedRate > 0) { - val estimatedFee = (feeRate.toDouble() / selectedRate) * selectedFee - totalSats = estimatedFee.toLong() - - // create estimated custom fee option so it shows up when Done is pressed - val estimatedFeeOption = - FeeRateOptionWithTotalFee( - feeSpeed = feeSpeed, - feeRate = FeeRate.fromSatPerVb(feeRate), - totalFee = Amount.fromSat(estimatedFee.toULong()), - ) - updatedFeeOptions = updatedFeeOptions.addCustomFeeRate(estimatedFeeOption) - } + val estimatedFeeOption = + estimatedCustomFeeOption( + feeRate = feeRate, + feeSpeed = feeSpeed, + selectedOption = selectedOption, + ) ?: return + + totalSats = estimatedFeeOption.totalFee()?.asSats()?.toLong() + updatedFeeOptions = updatedFeeOptions.addCustomFeeRate(estimatedFeeOption) return } @@ -159,24 +203,18 @@ fun CustomFeeRateSheet( return@withContext } - withContext(Dispatchers.Main) { - presenter.erroredFeeRate = feeRate - - if (presenter.lastWorkingFeeRate != null) { - onDismiss() - delay(CustomFeeRateConstants.ALERT_DELAY_MS) - presenter.alertState = - TaggedItem( - SendFlowAlertState.General( - title = "Fee too high!", - message = "The fee rate you entered is too high, we automatically selected a lower fee", - ), - ) - } - } + handleTooHighCustomFee( + feeRate = feeRate, + presenter = presenter, + onDismiss = onDismiss, + ) } catch (e: Exception) { // unexpected error during fee calculation - android.util.Log.e("CustomFeeRateSheet", "Unexpected error calculating fee: ${e.javaClass.simpleName} - ${e.message}", e) + android.util.Log.e( + "CustomFeeRateSheet", + "Unexpected error calculating fee: ${e.javaClass.simpleName} - ${e.message}", + e, + ) withContext(Dispatchers.Main) { // keep previous total sats value, don't crash } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sheets/FeeRateSelectorSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/sheets/FeeRateSelectorSheet.kt index 0d9bdf5a1..22c3ed1da 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sheets/FeeRateSelectorSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sheets/FeeRateSelectorSheet.kt @@ -26,7 +26,6 @@ import org.bitcoinppl.cove_core.types.FeeRateOptionWithTotalFee import org.bitcoinppl.cove_core.types.FeeRateOptionsWithTotalFee import org.bitcoinppl.cove_core.types.FeeSpeed import org.bitcoinppl.cove_core.types.feeSpeedToCircleColor -import java.util.Locale /** fee rate selector sheet - displays fast/medium/slow fee options */ @OptIn(ExperimentalMaterial3Api::class) @@ -244,7 +243,7 @@ private fun FeeOptionCard( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${String.format(Locale.US, "%.2f", feeOption.satPerVb())} sat/vB", + text = feeOption.satsPerVbyteString(), style = MaterialTheme.typography.bodyMedium, color = contentColor, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/CustomBaseViews.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/CustomBaseViews.kt index 8d82a2eb6..63012a1e9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/CustomBaseViews.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/CustomBaseViews.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -105,39 +106,105 @@ fun MaterialDividerPreview() { MaterialDivider(indent = MaterialSpacing.dividerIndent) } +@Composable +@Suppress("FunctionNaming", "LongParameterList") +fun KeyValueRow( + label: String, + value: String, + modifier: Modifier = Modifier, + labelWeight: Float = 1f, + valueWeight: Float = 1f, + labelStyle: TextStyle = MaterialTheme.typography.bodyLarge, + valueStyle: TextStyle = MaterialTheme.typography.bodyLarge, + labelColor: Color = Color.Unspecified, + valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + labelTextAlign: TextAlign = TextAlign.Start, + valueTextAlign: TextAlign = TextAlign.End, + labelMaxLines: Int = Int.MAX_VALUE, + valueMaxLines: Int = Int.MAX_VALUE, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + KeyValueRow( + modifier = modifier, + labelWeight = labelWeight, + valueWeight = valueWeight, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + labelContent = { + Text( + modifier = Modifier.fillMaxWidth(), + text = label, + style = labelStyle, + color = labelColor, + textAlign = labelTextAlign, + maxLines = labelMaxLines, + ) + }, + valueContent = { + Text( + text = value, + modifier = Modifier.fillMaxWidth(), + style = valueStyle, + color = valueColor, + textAlign = valueTextAlign, + maxLines = valueMaxLines, + ) + }, + trailingContent = trailingContent, + ) +} + +@Composable +@Suppress("FunctionNaming", "LongParameterList") +fun KeyValueRow( + modifier: Modifier = Modifier, + labelWeight: Float = 1f, + valueWeight: Float? = 1f, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + labelContent: @Composable RowScope.() -> Unit, + valueContent: @Composable RowScope.() -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + ) { + Row( + modifier = Modifier.weight(labelWeight), + verticalAlignment = verticalAlignment, + content = labelContent, + ) + + val valueModifier = valueWeight?.let { Modifier.weight(it) } ?: Modifier + Row( + modifier = valueModifier, + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.End, + content = valueContent, + ) + + trailingContent() + } +} + @Composable fun InfoRow( label: String, text: String, ) { - Row( + KeyValueRow( + label = label, + value = text, modifier = Modifier .fillMaxWidth() .padding(top = 6.dp, bottom = 6.dp, start = 8.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, - ) { - Text( - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - text = label, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Start, - ) - Text( - text = text, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - ) - } + ) } @Preview @@ -153,39 +220,22 @@ fun ClickableInfoRow( icon: ImageVector, onClick: () -> Unit, ) { - Row( + KeyValueRow( + label = label, + value = text, modifier = Modifier .fillMaxWidth() .padding(top = 6.dp, bottom = 6.dp, start = 8.dp, end = 16.dp) .clickable(true, onClick = onClick), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - text = label, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Start, - ) - Text( - text = text, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - ) - Icon( - imageVector = icon, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - contentDescription = "Forward", - ) - } + trailingContent = { + Icon( + imageVector = icon, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = "Forward", + ) + }, + ) } @Composable diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 9d266df50..1adb911f9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -783,9 +783,6 @@ internal interface UniffiCallbackInterfaceCoinControlManagerReconcilerMethod0 : internal interface UniffiCallbackInterfaceCoinControlManagerReconcilerMethod1 : com.sun.jna.Callback { fun callback(`uniffiHandle`: Long,`messages`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) } -internal interface UniffiCallbackInterfaceImportWalletManagerReconcilerMethod0 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`message`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} internal interface UniffiCallbackInterfaceOnboardingManagerReconcilerMethod0 : com.sun.jna.Callback { fun callback(`uniffiHandle`: Long,`message`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) } @@ -897,25 +894,6 @@ internal open class UniffiVTableCallbackInterfaceCoinControlManagerReconciler( `reconcileMany` = other.`reconcileMany` } -} -@Structure.FieldOrder("uniffiFree", "uniffiClone", "reconcile") -internal open class UniffiVTableCallbackInterfaceImportWalletManagerReconciler( - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, - @JvmField internal var `uniffiClone`: UniffiCallbackInterfaceClone? = null, - @JvmField internal var `reconcile`: UniffiCallbackInterfaceImportWalletManagerReconcilerMethod0? = null, -) : Structure() { - class UniffiByValue( - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - `uniffiClone`: UniffiCallbackInterfaceClone? = null, - `reconcile`: UniffiCallbackInterfaceImportWalletManagerReconcilerMethod0? = null, - ): UniffiVTableCallbackInterfaceImportWalletManagerReconciler(`uniffiFree`,`uniffiClone`,`reconcile`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceImportWalletManagerReconciler) { - `uniffiFree` = other.`uniffiFree` - `uniffiClone` = other.`uniffiClone` - `reconcile` = other.`reconcile` - } - } @Structure.FieldOrder("uniffiFree", "uniffiClone", "reconcile") internal open class UniffiVTableCallbackInterfaceOnboardingManagerReconciler( @@ -1519,12 +1497,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustconnectivitymanager_state( ): Short - external fun uniffi_cove_checksum_method_rustimportwalletmanager_dispatch( - ): Short external fun uniffi_cove_checksum_method_rustimportwalletmanager_import_wallet( ): Short - external fun uniffi_cove_checksum_method_rustimportwalletmanager_listen_for_updates( - ): Short external fun uniffi_cove_checksum_method_rustonboardingmanager_current_wallet_id( ): Short external fun uniffi_cove_checksum_method_rustonboardingmanager_dispatch( @@ -1697,6 +1671,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_display_amount_with_direction( ): Short + external fun uniffi_cove_checksum_method_rustwalletmanager_display_confirmation_count( + ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_display_fiat_amount( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_display_fiat_amount_pending_fmt( @@ -2077,8 +2053,6 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_coincontrolmanagerreconciler_reconcile_many( ): Short - external fun uniffi_cove_checksum_method_importwalletmanagerreconciler_reconcile( - ): Short external fun uniffi_cove_checksum_method_onboardingmanagerreconciler_reconcile( ): Short external fun uniffi_cove_checksum_method_pendingwalletmanagerreconciler_reconcile( @@ -2118,7 +2092,6 @@ internal object UniffiLib { uniffiCallbackInterfaceCloudBackupManagerReconciler.register(this) uniffiCallbackInterfaceCoinControlManagerReconciler.register(this) uniffiCallbackInterfaceFfiReconcile.register(this) - uniffiCallbackInterfaceImportWalletManagerReconciler.register(this) uniffiCallbackInterfaceOnboardingManagerReconciler.register(this) uniffiCallbackInterfacePendingWalletManagerReconciler.register(this) uniffiCallbackInterfaceSendFlowManagerReconciler.register(this) @@ -2615,12 +2588,8 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_constructor_rustimportwalletmanager_new(uniffi_out_err: UniffiRustCallStatus, ): Long - external fun uniffi_cove_fn_method_rustimportwalletmanager_dispatch(`ptr`: Long,`action`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit external fun uniffi_cove_fn_method_rustimportwalletmanager_import_wallet(`ptr`: Long,`enteredWords`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_cove_fn_method_rustimportwalletmanager_listen_for_updates(`ptr`: Long,`reconciler`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit external fun uniffi_cove_fn_clone_rustonboardingmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_rustonboardingmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2827,6 +2796,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustwalletmanager_display_amount_with_direction(`ptr`: Long,`amount`: Long,`direction`: RustBufferTransactionDirection.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_rustwalletmanager_display_confirmation_count(`ptr`: Long,`confirmations`: Int,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustwalletmanager_display_fiat_amount(`ptr`: Long,`amount`: Double,`withSuffix`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustwalletmanager_display_fiat_amount_pending_fmt(`ptr`: Long,`amount`: Double,`withSuffix`: Byte,uniffi_out_err: UniffiRustCallStatus, @@ -3259,8 +3230,6 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_init_callback_vtable_coincontrolmanagerreconciler(`vtable`: UniffiVTableCallbackInterfaceCoinControlManagerReconciler, ): Unit - external fun uniffi_cove_fn_init_callback_vtable_importwalletmanagerreconciler(`vtable`: UniffiVTableCallbackInterfaceImportWalletManagerReconciler, - ): Unit external fun uniffi_cove_fn_init_callback_vtable_onboardingmanagerreconciler(`vtable`: UniffiVTableCallbackInterfaceOnboardingManagerReconciler, ): Unit external fun uniffi_cove_fn_init_callback_vtable_pendingwalletmanagerreconciler(`vtable`: UniffiVTableCallbackInterfacePendingWalletManagerReconciler, @@ -3425,6 +3394,8 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_method_hardwarewalletmetadata_istapsigner(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte + external fun uniffi_cove_fn_method_walletbirthday_block_height_fmt(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_wallettype_display_name(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_wallettype_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -4365,15 +4336,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustconnectivitymanager_state() != 43225.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustimportwalletmanager_dispatch() != 6624.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_rustimportwalletmanager_import_wallet() != 59354.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustimportwalletmanager_listen_for_updates() != 1669.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_rustonboardingmanager_current_wallet_id() != 41633.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4632,6 +4597,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustwalletmanager_display_amount_with_direction() != 18925.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_display_confirmation_count() != 48659.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustwalletmanager_display_fiat_amount() != 25193.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -5202,9 +5170,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_coincontrolmanagerreconciler_reconcile_many() != 55187.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_importwalletmanagerreconciler_reconcile() != 55979.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_onboardingmanagerreconciler_reconcile() != 11875.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -19581,18 +19546,11 @@ public object FfiConverterTypeRustConnectivityManager: FfiConverter>): WalletMetadata - fun `listenForUpdates`(`reconciler`: ImportWalletManagerReconciler) - companion object } @@ -19706,22 +19664,6 @@ open class RustImportWalletManager: Disposable, AutoCloseable, RustImportWalletM } - /** - * Action from the frontend to change the state of the view model - */override fun `dispatch`(`action`: ImportWalletManagerAction) - = - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_rustimportwalletmanager_dispatch( - it, - - FfiConverterTypeImportWalletManagerAction.lower(`action`),_status) -} - } - - - - /** * Import wallet view from entered words */ @@ -19739,19 +19681,6 @@ open class RustImportWalletManager: Disposable, AutoCloseable, RustImportWalletM } - override fun `listenForUpdates`(`reconciler`: ImportWalletManagerReconciler) - = - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_rustimportwalletmanager_listen_for_updates( - it, - - FfiConverterTypeImportWalletManagerReconciler.lower(`reconciler`),_status) -} - } - - - @@ -21354,6 +21283,8 @@ public interface RustWalletManagerInterface { */ fun `displayAmountWithDirection`(`amount`: Amount, `direction`: TransactionDirection): kotlin.String + fun `displayConfirmationCount`(`confirmations`: kotlin.UInt): kotlin.String + fun `displayFiatAmount`(`amount`: kotlin.Double, `withSuffix`: kotlin.Boolean = true): kotlin.String /** @@ -22425,6 +22356,20 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf } + override fun `displayConfirmationCount`(`confirmations`: kotlin.UInt): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_display_confirmation_count( + it, + + FfiConverterUInt.lower(`confirmations`),_status) +} + } + ) + } + + override fun `displayFiatAmount`(`amount`: kotlin.Double, `withSuffix`: kotlin.Boolean): kotlin.String { return FfiConverterString.lift( callWithHandle { @@ -28276,34 +28221,6 @@ public object FfiConverterTypeAppState: FfiConverterRustBuffer { -class AuthManagerState { - override fun equals(other: Any?): Boolean { - return other is AuthManagerState - } - - override fun hashCode(): Int { - return javaClass.hashCode() - } - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeAuthManagerState: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): AuthManagerState { - return AuthManagerState() - } - - override fun allocationSize(value: AuthManagerState) = 0UL - - override fun write(value: AuthManagerState, buf: ByteBuffer) { - } -} - - - /** * Report of what happened during a backup import */ @@ -29032,47 +28949,6 @@ public object FfiConverterTypeCloudBackupRestoreReport: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): CloudBackupRetryContext { - return CloudBackupRetryContext( - FfiConverterTypeCloudBackupRetryIssue.read(buf), - FfiConverterTypeCloudBackupRetryAction.read(buf), - ) - } - - override fun allocationSize(value: CloudBackupRetryContext) = ( - FfiConverterTypeCloudBackupRetryIssue.allocationSize(value.`issue`) + - FfiConverterTypeCloudBackupRetryAction.allocationSize(value.`action`) - ) - - override fun write(value: CloudBackupRetryContext, buf: ByteBuffer) { - FfiConverterTypeCloudBackupRetryIssue.write(value.`issue`, buf) - FfiConverterTypeCloudBackupRetryAction.write(value.`action`, buf) - } -} - - - /** * Top-level state snapshot exposed to platform managers */ @@ -29891,34 +29767,6 @@ public object FfiConverterTypeHistoricalPriceRecord: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): ImportWalletManagerState { - return ImportWalletManagerState() - } - - override fun allocationSize(value: ImportWalletManagerState) = 0UL - - override fun write(value: ImportWalletManagerState, buf: ByteBuffer) { - } -} - - - data class InternalOnlyMetadata ( var `addressIndex`: AddressIndex? , @@ -37221,42 +37069,6 @@ public object FfiConverterTypeCloudBackupRetryAction: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer) = try { - CloudBackupRetryIssue.values()[buf.getInt() - 1] - } catch (e: IndexOutOfBoundsException) { - throw RuntimeException("invalid enum value, something is very wrong!!", e) - } - - override fun allocationSize(value: CloudBackupRetryIssue) = 4UL - - override fun write(value: CloudBackupRetryIssue, buf: ByteBuffer) { - buf.putInt(value.ordinal + 1) - } -} - - - - - /** * Root-level prompt the UI should show for the current cloud backup state */ @@ -39929,7 +39741,7 @@ sealed class DeepVerificationFailure { data class Retry( val `message`: kotlin.String, val `detail`: org.bitcoinppl.cove_core.CloudBackupDetail?, - val `retryContext`: org.bitcoinppl.cove_core.CloudBackupRetryContext?) : DeepVerificationFailure() + val `retryAction`: org.bitcoinppl.cove_core.CloudBackupRetryAction?) : DeepVerificationFailure() { @@ -40007,7 +39819,7 @@ public object FfiConverterTypeDeepVerificationFailure : FfiConverterRustBuffer DeepVerificationFailure.Retry( FfiConverterString.read(buf), FfiConverterOptionalTypeCloudBackupDetail.read(buf), - FfiConverterOptionalTypeCloudBackupRetryContext.read(buf), + FfiConverterOptionalTypeCloudBackupRetryAction.read(buf), ) 2 -> DeepVerificationFailure.RecreateManifest( FfiConverterString.read(buf), @@ -40034,7 +39846,7 @@ public object FfiConverterTypeDeepVerificationFailure : FfiConverterRustBuffer { @@ -40071,7 +39883,7 @@ public object FfiConverterTypeDeepVerificationFailure : FfiConverterRustBuffer { @@ -42542,72 +42354,6 @@ public object FfiConverterTypeImportWalletError : FfiConverterRustBuffer { - override fun read(buf: ByteBuffer) = try { - ImportWalletManagerAction.values()[buf.getInt() - 1] - } catch (e: IndexOutOfBoundsException) { - throw RuntimeException("invalid enum value, something is very wrong!!", e) - } - - override fun allocationSize(value: ImportWalletManagerAction) = 4UL - - override fun write(value: ImportWalletManagerAction, buf: ByteBuffer) { - buf.putInt(value.ordinal + 1) - } -} - - - - - - -enum class ImportWalletManagerReconcileMessage { - - NO_OP; - - - - - companion object -} - - -/** - * @suppress - */ -public object FfiConverterTypeImportWalletManagerReconcileMessage: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer) = try { - ImportWalletManagerReconcileMessage.values()[buf.getInt() - 1] - } catch (e: IndexOutOfBoundsException) { - throw RuntimeException("invalid enum value, something is very wrong!!", e) - } - - override fun allocationSize(value: ImportWalletManagerReconcileMessage) = 4UL - - override fun write(value: ImportWalletManagerReconcileMessage, buf: ByteBuffer) { - buf.putInt(value.ordinal + 1) - } -} - - - - - - sealed class InitException(message: String): kotlin.Exception(message) { @@ -52566,6 +52312,16 @@ sealed class WalletBirthday { + fun `blockHeightFmt`(): kotlin.String? { + return FfiConverterOptionalString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_walletbirthday_block_height_fmt(FfiConverterTypeWalletBirthday.lower(this), + _status) +} + ) + } + + @@ -56876,69 +56632,6 @@ public object FfiConverterTypeFfiReconcile: FfiConverterCallbackInterface - uniffiObj.`reconcile`( - FfiConverterTypeImportWalletManagerReconcileMessage.lift(`message`), - ) - } - val writeReturn = { _: Unit -> Unit } - uniffiTraitInterfaceCall(uniffiCallStatus, makeCall, writeReturn) - } - } - - internal object uniffiFree: UniffiCallbackInterfaceFree { - override fun callback(handle: Long) { - FfiConverterTypeImportWalletManagerReconciler.handleMap.remove(handle) - } - } - - internal object uniffiClone: UniffiCallbackInterfaceClone { - override fun callback(handle: Long): Long { - return FfiConverterTypeImportWalletManagerReconciler.handleMap.clone(handle) - } - } - - internal var vtable = UniffiVTableCallbackInterfaceImportWalletManagerReconciler.UniffiByValue( - uniffiFree, - uniffiClone, - `reconcile`, - ) - - // Registers the foreign callback with the Rust side. - // This method is generated for each callback interface. - internal fun register(lib: UniffiLib) { - lib.uniffi_cove_fn_init_callback_vtable_importwalletmanagerreconciler(vtable) - } -} - -/** - * The ffiConverter which transforms the Callbacks in to handles to pass to Rust. - * - * @suppress - */ -public object FfiConverterTypeImportWalletManagerReconciler: FfiConverterCallbackInterface() - - - - - public interface OnboardingManagerReconciler { fun `reconcile`(`message`: OnboardingReconcileMessage) @@ -57902,38 +57595,6 @@ public object FfiConverterOptionalTypeCloudBackupProgress: FfiConverterRustBuffe -/** - * @suppress - */ -public object FfiConverterOptionalTypeCloudBackupRetryContext: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): CloudBackupRetryContext? { - if (buf.get().toInt() == 0) { - return null - } - return FfiConverterTypeCloudBackupRetryContext.read(buf) - } - - override fun allocationSize(value: CloudBackupRetryContext?): ULong { - if (value == null) { - return 1UL - } else { - return 1UL + FfiConverterTypeCloudBackupRetryContext.allocationSize(value) - } - } - - override fun write(value: CloudBackupRetryContext?, buf: ByteBuffer) { - if (value == null) { - buf.put(0) - } else { - buf.put(1) - FfiConverterTypeCloudBackupRetryContext.write(value, buf) - } - } -} - - - - /** * @suppress */ @@ -58190,6 +57851,38 @@ public object FfiConverterOptionalTypeBlockSizeLast: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): CloudBackupRetryAction? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeCloudBackupRetryAction.read(buf) + } + + override fun allocationSize(value: CloudBackupRetryAction?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeCloudBackupRetryAction.allocationSize(value) + } + } + + override fun write(value: CloudBackupRetryAction?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeCloudBackupRetryAction.write(value, buf) + } + } +} + + + + /** * @suppress */ diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/types/cove_types.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/types/cove_types.kt index 59e4f5cf9..e2414025b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/types/cove_types.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/types/cove_types.kt @@ -805,6 +805,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_types_checksum_method_feerate_sat_per_vb( ): Short + external fun uniffi_cove_types_checksum_method_feerate_sats_per_vbyte_string( + ): Short external fun uniffi_cove_types_checksum_method_feerateoption_duration( ): Short external fun uniffi_cove_types_checksum_method_feerateoption_fee_rate( @@ -815,6 +817,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_types_checksum_method_feerateoption_sat_per_vb( ): Short + external fun uniffi_cove_types_checksum_method_feerateoption_sats_per_vbyte_string( + ): Short external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_duration( ): Short external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_rate( @@ -829,6 +833,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sat_per_vb( ): Short + external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sats_per_vbyte_string( + ): Short external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_total_fee( ): Short external fun uniffi_cove_types_checksum_method_feerateoptions_fast( @@ -1109,6 +1115,8 @@ internal object UniffiLib { ): Long external fun uniffi_cove_types_fn_method_feerate_sat_per_vb(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float + external fun uniffi_cove_types_fn_method_feerate_sats_per_vbyte_string(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_types_fn_clone_feerateoption(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_types_fn_free_feerateoption(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1125,6 +1133,8 @@ internal object UniffiLib { ): Byte external fun uniffi_cove_types_fn_method_feerateoption_sat_per_vb(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float + external fun uniffi_cove_types_fn_method_feerateoption_sats_per_vbyte_string(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_types_fn_clone_feerateoptionwithtotalfee(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_types_fn_free_feerateoptionwithtotalfee(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1145,6 +1155,8 @@ internal object UniffiLib { ): Byte external fun uniffi_cove_types_fn_method_feerateoptionwithtotalfee_sat_per_vb(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float + external fun uniffi_cove_types_fn_method_feerateoptionwithtotalfee_sats_per_vbyte_string(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_feerateoptionwithtotalfee_total_fee(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_types_fn_clone_feerateoptions(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1634,6 +1646,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_method_feerate_sat_per_vb() != 25940.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_feerate_sats_per_vbyte_string() != 28324.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_method_feerateoption_duration() != 37929.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1649,6 +1664,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_method_feerateoption_sat_per_vb() != 38044.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_feerateoption_sats_per_vbyte_string() != 46546.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_duration() != 34003.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1670,6 +1688,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sat_per_vb() != 61796.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sats_per_vbyte_string() != 22579.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_total_fee() != 12787.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4885,6 +4906,8 @@ public interface FeeRateInterface { fun `satPerVb`(): kotlin.Float + fun `satsPerVbyteString`(): kotlin.String + companion object } @@ -5002,6 +5025,19 @@ open class FeeRate: Disposable, AutoCloseable, FeeRateInterface } + override fun `satsPerVbyteString`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_feerate_sats_per_vbyte_string( + it, + _status) +} + } + ) + } + + @@ -5158,6 +5194,8 @@ public interface FeeRateOptionInterface { fun `satPerVb`(): kotlin.Float + fun `satsPerVbyteString`(): kotlin.String + companion object } @@ -5338,6 +5376,19 @@ open class FeeRateOption: Disposable, AutoCloseable, FeeRateOptionInterface } + override fun `satsPerVbyteString`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_feerateoption_sats_per_vbyte_string( + it, + _status) +} + } + ) + } + + @@ -5488,6 +5539,8 @@ public interface FeeRateOptionWithTotalFeeInterface { fun `satPerVb`(): kotlin.Float + fun `satsPerVbyteString`(): kotlin.String + fun `totalFee`(): Amount? companion object @@ -5697,6 +5750,19 @@ open class FeeRateOptionWithTotalFee: Disposable, AutoCloseable, FeeRateOptionWi } + override fun `satsPerVbyteString`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_feerateoptionwithtotalfee_sats_per_vbyte_string( + it, + _status) +} + } + ) + } + + override fun `totalFee`(): Amount? { return FfiConverterOptionalTypeAmount.lift( callWithHandle { diff --git a/android/app/src/test/java/org/bitcoinppl/cove/SendFlowRouteLifecycleTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/SendFlowRouteLifecycleTest.kt index b45e4d236..90f2491ab 100644 --- a/android/app/src/test/java/org/bitcoinppl/cove/SendFlowRouteLifecycleTest.kt +++ b/android/app/src/test/java/org/bitcoinppl/cove/SendFlowRouteLifecycleTest.kt @@ -2,11 +2,25 @@ package org.bitcoinppl.cove import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.SendRoute +import org.bitcoinppl.cove_core.SettingsRoute import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class SendFlowRouteLifecycleTest { + @Test + fun defaultSendRouteCountsAsActiveSendFlow() { + val walletId = "wallet-a" + + assertTrue( + routeStackContainsSendWallet( + default = Route.Send(SendRoute.SetAmount(walletId, null, null)), + routes = emptyList(), + walletId = walletId, + ), + ) + } + @Test fun stackStillContainsSendWalletDuringSendSubrouteTransitions() { val walletId = "wallet-a" @@ -24,6 +38,19 @@ class SendFlowRouteLifecycleTest { ) } + @Test + fun nestedSendRouteCountsAsActiveWhenDefaultRouteIsElsewhere() { + val walletId = "wallet-a" + + assertTrue( + routeStackContainsSendWallet( + default = Route.Settings(SettingsRoute.Main), + routes = listOf(Route.Send(SendRoute.SetAmount(walletId, null, null))), + walletId = walletId, + ), + ) + } + @Test fun stackDoesNotContainSendWalletAfterLeavingSendFlow() { val walletId = "wallet-a" diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccessTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccessTest.kt index 5a70028c2..1f0f4025c 100644 --- a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccessTest.kt +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidCloudStorageAccessTest.kt @@ -1,986 +1,14 @@ package org.bitcoinppl.cove.cloudbackup -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.common.api.CommonStatusCodes -import com.google.android.gms.common.api.Status -import java.io.BufferedReader -import java.io.IOException -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.ServerSocket -import java.net.SocketException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield import org.bitcoinppl.cove_core.device.CloudAccessPolicy import org.bitcoinppl.cove_core.device.CloudStorageException import org.bitcoinppl.cove_core.device.RemoteBackupLocation -import org.json.JSONObject import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class AndroidCloudStorageAccessTest { - private val testDrivePathNames = DrivePathNames( - namespacesRootFolderName = "cspp-namespaces", - masterKeyFolderName = "master-key", - walletsFolderName = "wallets", - walletFilePrefix = "wallet-", - ) - - @Test - fun createUploadMetadataIncludesParents() { - val metadata = createUploadMetadata(fileName = "wallet-record.json", parentId = "folder-123") - - assertEquals("wallet-record.json", metadata.name) - assertEquals(listOf("folder-123"), metadata.parents) - - val json = metadata.toJson() - assertEquals("wallet-record.json", json.getString("name")) - assertEquals("folder-123", json.getJSONArray("parents").getString(0)) - } - - @Test - fun overwriteUploadMetadataOmitsParents() { - val metadata = overwriteUploadMetadata(fileName = "wallet-record.json") - - assertEquals("wallet-record.json", metadata.name) - assertEquals(emptyList(), metadata.parents) - - val json = metadata.toJson() - assertEquals("wallet-record.json", json.getString("name")) - assertFalse(json.has("parents")) - } - - @Test - fun driveAboutResponseProvidesAccountIdentity() { - val response = - JSONObject( - """ - { - "user": { - "emailAddress": "Person@Example.com", - "permissionId": "account-1" - } - } - """.trimIndent(), - ) - - assertEquals( - DriveAccountIdentity(drivePermissionId = "account-1", email = "person@example.com"), - driveAccountIdentityFromAboutResponse(response), - ) - } - - @Test - fun driveAboutResponseOmitsBlankIdentity() { - assertEquals(null, driveAccountIdentityFromAboutResponse(JSONObject("{}"))) - assertEquals( - null, - driveAccountIdentityFromAboutResponse( - JSONObject( - """ - { - "user": { - "emailAddress": " ", - "permissionId": "" - } - } - """.trimIndent(), - ), - ), - ) - } - - @Test - fun driveAccountIdentityNormalizesEmailForEqualityAndHashCode() { - val first = DriveAccountIdentity(googleAccountId = "account-1", email = " Person@Example.com ") - val second = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - - assertEquals(first, second) - assertEquals(first.hashCode(), second.hashCode()) - assertEquals("person@example.com", first.email) - } - - @Test - fun driveAccountIdentityKeepsIdSourcesSeparate() { - val googleIdentity = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val driveIdentity = DriveAccountIdentity(drivePermissionId = "permission-1", email = "PERSON@example.com") - - assertTrue(googleIdentity.matches(driveIdentity)) - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "person@example.com", - ), - googleIdentity.verifiedMerge(driveIdentity), - ) - } - - @Test - fun driveAccountIdentityMergePoliciesHandleEmailRefreshes() { - val original = DriveAccountIdentity(googleAccountId = "account-1", email = "old@example.com") - val refreshed = DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "new@example.com", - ) - - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "old@example.com", - ), - original.withMissingFieldsFrom(refreshed), - ) - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "new@example.com", - ), - original.verifiedMerge(refreshed), - ) - } - - @Test - fun driveAccountIdentityMergePoliciesFallbackWithoutRefreshedEmail() { - val original = DriveAccountIdentity(googleAccountId = "account-1", email = "old@example.com") - val withoutEmail = DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = null, - ) - - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "old@example.com", - ), - original.withMissingFieldsFrom(withoutEmail), - ) - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "old@example.com", - ), - original.verifiedMerge(withoutEmail), - ) - } - - @Test - fun driveAccountIdentityWithMissingFieldsDoesNotOverwriteExistingIds() { - val original = DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "person@example.com", - ) - val other = DriveAccountIdentity( - googleAccountId = "account-2", - drivePermissionId = "permission-2", - email = "other@example.com", - ) - - assertEquals(original, original.withMissingFieldsFrom(other)) - } - - @Test - fun driveApiUrlEncodesQueryParameters() { - assertEquals( - "https://example.test/files?q=name%20%3D%20%27a%20b%2Bc%27&email=a%2Bb%40example.com", - driveApiUrl( - "https://example.test/files", - listOf("q" to "name = 'a b+c'", "email" to "a+b@example.com"), - ), - ) - } - - @Test - fun driveApiUrlKeepsExistingQueryAndFragmentOrder() { - assertEquals( - "https://example.test/files?alt=json&fields=files%28id%2Cname%29#metadata", - driveApiUrl( - "https://example.test/files?alt=json#metadata", - listOf("fields" to "files(id,name)"), - ), - ) - assertEquals( - "https://example.test/files#metadata", - driveApiUrl("https://example.test/files#metadata", emptyList()), - ) - } - - @Test - fun driveLocationPartsKeepsFlatFilesAtNamespaceRoot() { - assertEquals( - DriveLocationParts(parentFolders = emptyList(), fileName = "wallet-record.json"), - driveLocationParts("wallet-record.json"), - ) - } - - @Test - fun driveLocationPartsSplitsKindPrefixedFiles() { - assertEquals( - DriveLocationParts(parentFolders = listOf("wallets"), fileName = "wallet-record.json"), - driveLocationParts("wallets/wallet-record.json"), - ) - } - - @Test - fun driveLocationPartsRejectsParentTraversal() { - val error = runCatching { driveLocationParts("wallets/../wallet-record.json") } - .exceptionOrNull() - - assertTrue(error is IllegalArgumentException) - } - - @Test - fun driveLocationPartsRejectsBlankRelativePath() { - val error = runCatching { driveLocationParts("") } - .exceptionOrNull() - - assertTrue(error is IllegalArgumentException) - assertEquals("relativePath must not be blank", error?.message) - } - - @Test - fun drivePathsAcceptLegacyFlatAndKindPrefixedWalletLocations() { - assertTrue( - isWalletFileLocation( - location = "wallet-record.json", - walletFilePrefix = "wallet-", - walletsFolderName = "wallets", - ), - ) - assertTrue( - isWalletFileLocation( - location = "wallets/wallet-record.json", - walletFilePrefix = "wallet-", - walletsFolderName = "wallets", - ), - ) - assertFalse( - isWalletFileLocation( - location = "master-key/wallet-record.json", - walletFilePrefix = "wallet-", - walletsFolderName = "wallets", - ), - ) - } - - @Test - fun tokenAcquisitionFailuresPreserveAuthorizationMessages() = - runBlocking { - val storage = - AndroidCloudStorageAccess( - FailingDriveAuthorization(AuthorizationRequiredException("consent required")), - TestDriveAccountBindingStore(), - ) - - val error = - try { - storage.listNamespaces(CloudAccessPolicy.SILENT) - null - } catch (error: Throwable) { - error - } - - assertTrue(error is CloudStorageException.AuthorizationRequired) - assertEquals( - "consent required", - (error as CloudStorageException.AuthorizationRequired).v1, - ) - } - - @Test - fun unauthorizedDriveAccountIdentityLookupClearsTokenAndRetries() = - runBlocking { - assertDriveAccountIdentityLookupClearsTokenAndRetries( - statusCode = HttpURLConnection.HTTP_UNAUTHORIZED, - body = "expired token", - ) - } - - @Test - fun rejectedDriveAccountIdentityLookupClearsTokenAndRetries() = - runBlocking { - assertDriveAccountIdentityLookupClearsTokenAndRetries( - statusCode = HttpURLConnection.HTTP_FORBIDDEN, - body = driveErrorBody("insufficientPermissions"), - ) - } - - @Test - fun repeatedRejectedDriveAccountIdentityLookupIdentifiesVerificationFailure() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue(HttpURLConnection.HTTP_FORBIDDEN, driveErrorBody("insufficientPermissions")) - server.enqueue(HttpURLConnection.HTTP_FORBIDDEN, driveErrorBody("insufficientPermissions")) - - val authorization = SequentialDriveAuthorization(listOf("token-1", "token-2")) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = TestDriveAccountBindingStore(), - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - val error = captureError { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - - assertTrue(error is CloudStorageException.AuthorizationRequired) - assertEquals( - "google drive identity verification was rejected", - (error as CloudStorageException.AuthorizationRequired).v1, - ) - assertEquals(listOf(false, false), authorization.accessRequests) - assertEquals(listOf("token-1", "token-2"), authorization.clearedTokens) - - val requests = server.requests() - assertEquals(listOf("/about", "/about"), requests.map { it.path.substringBefore("?") }) - } - } - - @Test - fun cachingDriveAuthorizationReusesUpdatedTokenIdentity() = - runBlocking { - var now = 0L - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { now }, - cacheWindowMs = 1_000, - ) - val unresolved = authorization.accessToken(interactive = false) - val resolved = unresolved.copy( - account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - - authorization.updateCachedToken(resolved) - - now = 500 - assertEquals(resolved, authorization.accessToken(interactive = false)) - assertEquals(listOf(false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDropsUpdatedTokenWhenCacheKeyChanges() = - runBlocking { - var cacheKey: Any? = "account-1" - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { cacheKey }, - ) - val unresolved = authorization.accessToken(interactive = false) - val resolved = unresolved.copy( - account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - - cacheKey = "account-2" - authorization.updateCachedToken(resolved) - - delegate.token = "token-2" - - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDropsUpdatedTokenWhenTokenChanges() = - runBlocking { - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - ) - val unresolved = authorization.accessToken(interactive = false) - val resolved = unresolved.copy( - token = "token-2", - account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - - authorization.updateCachedToken(resolved) - - delegate.token = "token-3" - - assertEquals("token-3", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDropsUpdatedTokenAfterExpiry() = - runBlocking { - var now = 0L - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { now }, - cacheWindowMs = 1_000, - ) - val unresolved = authorization.accessToken(interactive = false) - val resolved = unresolved.copy( - account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - - now = 1_000 - authorization.updateCachedToken(resolved) - - delegate.token = "token-2" - - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDropsUpdatedTokenWhenCacheKeyIsUnavailable() = - runBlocking { - var cacheKey: Any? = "account-1" - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { cacheKey }, - ) - val unresolved = authorization.accessToken(interactive = false) - val resolved = unresolved.copy( - account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - - cacheKey = null - authorization.updateCachedToken(resolved) - - delegate.token = "token-2" - - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun resolvedDriveAccountIdentityIsCachedAcrossOperations() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "emailAddress": "person@example.com", - "permissionId": "account-1" - } - } - """.trimIndent(), - ) - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - ) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = TestDriveAccountBindingStore(), - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) - assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) - assertEquals(listOf(false), delegate.accessRequests) - - val requests = server.requests() - assertEquals(listOf("/about", "/files", "/files"), requests.map { it.path.substringBefore("?") }) - assertEquals( - listOf("Bearer token-1", "Bearer token-1", "Bearer token-1"), - requests.map { it.authorization }, - ) - } - } - - @Test - fun sparseDriveAccountIdentityIsResolvedBeforeVerification() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "emailAddress": "person@example.com", - "permissionId": "account-1" - } - } - """.trimIndent(), - ) - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), - ) - val delegate = RecordingDriveAuthorization().apply { - account = DriveAccountIdentity(googleAccountId = "account-1", email = null) - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { store.selectedIdentity() }, - ) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = store, - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - val firstResult = runCatching { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - val secondResult = runCatching { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - assertTrue( - cloudStorageFailureMessage(firstResult.exceptionOrNull()), - firstResult.isSuccess, - ) - assertTrue( - cloudStorageFailureMessage(secondResult.exceptionOrNull()), - secondResult.isSuccess, - ) - assertEquals(emptyList(), firstResult.getOrNull()) - assertEquals(emptyList(), secondResult.getOrNull()) - assertEquals(listOf(false), delegate.accessRequests) - assertTrue(delegate.clearedTokens.isEmpty()) - - val requests = server.requests() - assertEquals(listOf("/about", "/files", "/files"), requests.map { it.path.substringBefore("?") }) - assertEquals( - listOf("Bearer token-1", "Bearer token-1", "Bearer token-1"), - requests.map { it.authorization }, - ) - } - } - - @Test - fun storedDrivePermissionIdIsResolvedBeforeBindingVerification() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "permissionId": "permission-1" - } - } - """.trimIndent(), - ) - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(drivePermissionId = "permission-1", email = null), - ) - val delegate = RecordingDriveAuthorization().apply { - account = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { store.selectedIdentity() }, - ) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = store, - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - val result = runCatching { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - - assertTrue( - cloudStorageFailureMessage(result.exceptionOrNull()), - result.isSuccess, - ) - assertEquals(emptyList(), result.getOrNull()) - assertEquals(listOf(false), delegate.accessRequests) - assertTrue(delegate.clearedTokens.isEmpty()) - - val requests = server.requests() - assertEquals(listOf("/about", "/files"), requests.map { it.path.substringBefore("?") }) - assertTrue(requests.first().path.contains("permissionId")) - assertEquals( - listOf("Bearer token-1", "Bearer token-1"), - requests.map { it.authorization }, - ) - } - } - - @Test - fun constrainedAuthorizationWithoutIdentityIsVerifiedThroughDriveAbout() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "emailAddress": "other@example.com", - "permissionId": "account-2" - } - } - """.trimIndent(), - ) - - val selected = DriveAccountIdentity(googleAccountId = null, email = "person@example.com") - val authorization = RecordingDriveAuthorization().apply { - account = null - } - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = TestDriveAccountBindingStore(selected), - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - val error = captureError { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - - assertTrue(error is CloudStorageException.AuthorizationRequired) - assertEquals( - "google drive account does not match the account selected for Cloud Backup", - (error as CloudStorageException.AuthorizationRequired).v1, - ) - assertEquals(listOf("token-1"), authorization.clearedTokens) - - val requests = server.requests() - assertEquals(listOf("/about"), requests.map { it.path.substringBefore("?") }) - assertTrue(requests.single().path.contains("permissionId")) - } - } - - @Test - fun unboundReadOnlyOperationsReuseResolvedDriveAccountIdentity() = - runBlocking { - TestDriveServer().use { server -> - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "emailAddress": "person@example.com", - "permissionId": "account-1" - } - } - """.trimIndent(), - ) - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - - val store = TestDriveAccountBindingStore() - val delegate = RecordingDriveAuthorization().apply { - account = null - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { driveAccountTokenCacheKey(store) }, - ) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = store, - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) - assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) - assertEquals(null, store.selectedIdentity()) - assertEquals(listOf(false), delegate.accessRequests) - - val requests = server.requests() - assertEquals(listOf("/about", "/files", "/files"), requests.map { it.path.substringBefore("?") }) - assertEquals( - listOf("Bearer token-1", "Bearer token-1", "Bearer token-1"), - requests.map { it.authorization }, - ) - } - } - - @Test - fun cachingDriveAuthorizationReusesTokenUntilCleared() = - runBlocking { - var now = 0L - val delegate = RecordingDriveAuthorization() - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { now }, - cacheWindowMs = 1_000, - ) - - assertEquals("token-1", authorization.accessToken(interactive = false).token) - assertEquals("token-1", authorization.accessToken(interactive = true).token) - assertEquals(listOf(false), delegate.accessRequests) - - now = 500 - delegate.token = "token-2" - authorization.clearToken("other-token") - assertEquals("token-1", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false), delegate.accessRequests) - - authorization.clearToken("token-1") - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - assertEquals(listOf("other-token", "token-1"), delegate.clearedTokens) - } - - @Test - fun cachingDriveAuthorizationExpiresIdleToken() = - runBlocking { - var now = 0L - val delegate = RecordingDriveAuthorization() - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { now }, - cacheWindowMs = 1_000, - ) - - assertEquals("token-1", authorization.accessToken(interactive = false).token) - - now = 999 - assertEquals("token-1", authorization.accessToken(interactive = false).token) - - now = 2_000 - delegate.token = "token-2" - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDoesNotCacheWhenCacheKeyIsUnavailable() = - runBlocking { - val delegate = RecordingDriveAuthorization() - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { null }, - ) - - assertEquals("token-1", authorization.accessToken(interactive = false).token) - - delegate.token = "token-2" - - assertEquals("token-2", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false), delegate.accessRequests) - } - - @Test - fun cachingDriveAuthorizationDoesNotRefreshWhileClearIsRunning() = - runTest { - val delegate = BlockingClearDriveAuthorization() - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - ) - - assertEquals("token-1", authorization.accessToken(interactive = false).token) - - delegate.token = "token-2" - val clear = async { authorization.clearToken("token-1") } - delegate.clearStarted.await() - - val refresh = async { authorization.accessToken(interactive = false).token } - yield() - - assertFalse(refresh.isCompleted) - - delegate.finishClear.complete(Unit) - clear.await() - - assertEquals("token-2", refresh.await()) - assertEquals(listOf(false, false), delegate.accessRequests) - assertEquals(listOf("token-1"), delegate.clearedTokens) - } - - @Test - fun authorizationRequiredErrorsPreserveMessagesAcrossOperations() { - val authorizationError = AuthorizationRequiredException("google drive authorization was cancelled") - val uploadError = mapDriveUploadError(authorizationError, "wallet-record") - val listError = mapDriveListError(authorizationError) - - assertTrue(uploadError is CloudStorageException.AuthorizationRequired) - assertTrue(listError is CloudStorageException.AuthorizationRequired) - assertEquals( - "google drive authorization was cancelled", - (uploadError as CloudStorageException.AuthorizationRequired).v1, - ) - assertEquals( - "google drive authorization was cancelled", - (listError as CloudStorageException.AuthorizationRequired).v1, - ) - } - - @Test - fun driveAccountBindingPersistsFirstAccountAndRejectsMismatch() { - val store = TestDriveAccountBindingStore() - val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val sameEmailFallback = DriveAccountIdentity(googleAccountId = null, email = "PERSON@example.com") - val mismatch = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") - - verifyDriveAccountBinding(store, first) - verifyDriveAccountBinding(store, sameEmailFallback) - - val error = runCatching { verifyDriveAccountBinding(store, mismatch) }.exceptionOrNull() - - assertTrue(error is DriveAccountBindingException.Mismatch) - } - - @Test - fun driveAccountBindingMatchesStoredGoogleIdToDrivePermissionIdByEmail() { - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com"), - ) - val fallback = DriveAccountIdentity(drivePermissionId = "permission-1", email = "PERSON@example.com") - - verifyDriveAccountBinding(store, fallback) - - assertEquals( - DriveAccountIdentity( - googleAccountId = "account-1", - drivePermissionId = "permission-1", - email = "person@example.com", - ), - store.selectedIdentity(), - ) - } - - @Test - fun driveAccountBindingValidationDoesNotPersistUnverifiedAccount() { - val store = TestDriveAccountBindingStore() - val probe = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - - verifyDriveAccountBinding(store, probe, bindIfMissing = false) - - assertEquals(null, store.selectedIdentity()) - } - - @Test - fun driveAccountBindingCanBeClearedAndRebound() { - val store = TestDriveAccountBindingStore() - val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val second = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") - - verifyDriveAccountBinding(store, first) - store.clearIdentity() - verifyDriveAccountBinding(store, second) - - assertEquals(second, store.selectedIdentity()) - } - - @Test - fun driveAccountBindingRejectsMissingIdentityWhenNoAccountIsSelected() { - val store = TestDriveAccountBindingStore() - - val error = runCatching { verifyDriveAccountBinding(store, identity = null) } - .exceptionOrNull() - - assertTrue(error is DriveAccountBindingException.MissingIdentity) - } - - @Test - fun driveAccountBindingRejectsMissingIdentityWhenAccountIsSelected() { - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com"), - ) - - val error = runCatching { verifyDriveAccountBinding(store, identity = null) } - .exceptionOrNull() - - assertTrue(error is DriveAccountBindingException.MissingIdentity) - } - - @Test - fun driveAccountBindingRejectsMissingIdentityWhenSelectedAccountCannotConstrainAuthorization() { - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(googleAccountId = "account-1", email = null), - ) - - val error = runCatching { verifyDriveAccountBinding(store, identity = null) } - .exceptionOrNull() - - assertTrue(error is DriveAccountBindingException.MissingIdentity) - } - - @Test - fun driveAccountBindingEnrichesSparseSelectedAccountAfterMatchingVerification() { - val store = TestDriveAccountBindingStore( - DriveAccountIdentity(googleAccountId = "account-1", email = null), - ) - val verified = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - - verifyDriveAccountBinding(store, verified) - - assertEquals(verified, store.selectedIdentity()) - } - @Test fun wrongDriveAccountBlocksOperationsBeforeRemoteMutation() = runBlocking { @@ -1035,159 +63,6 @@ class AndroidCloudStorageAccessTest { assertEquals(List(4) { "token-1" }, authorization.clearedTokens) } - @Test - fun cachedWrongDriveAccountTokenIsClearedAfterMismatch() = - runBlocking { - val selected = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val actual = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") - val store = TestDriveAccountBindingStore(selected) - val delegate = RecordingDriveAuthorization().apply { - account = actual - } - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { store.selectedIdentity() }, - ) - val storage = AndroidCloudStorageAccess(authorization, store) - - repeat(2) { - val error = captureError { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - - assertTrue(error is CloudStorageException.AuthorizationRequired) - } - - assertEquals(listOf(false, false), delegate.accessRequests) - assertEquals(listOf("token-1", "token-1"), delegate.clearedTokens) - } - - @Test - fun clearingDriveAccountBindingInvalidatesCachedToken() = - runBlocking { - val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val second = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") - val store = TestDriveAccountBindingStore(first) - val delegate = RecordingDriveAuthorization() - val authorization = CachingDriveAuthorization( - delegate = delegate, - elapsedRealtime = { 0 }, - cacheWindowMs = 1_000, - cacheKey = { store.selectedIdentity() }, - ) - - assertEquals("token-1", authorization.accessToken(interactive = false).token) - assertEquals("token-1", authorization.accessToken(interactive = false).token) - - store.clearIdentity() - delegate.token = "token-2" - delegate.account = second - - assertEquals("token-2", authorization.accessToken(interactive = false).token) - delegate.token = "token-3" - assertEquals("token-3", authorization.accessToken(interactive = false).token) - assertEquals(listOf(false, false, false), delegate.accessRequests) - } - - @Test - fun duplicateDriveFolderNamesAreDetected() { - assertEquals( - setOf("wallets"), - duplicateDriveFolderNames(listOf("master-key", "wallets", "wallets")), - ) - assertTrue(duplicateDriveFolderNames(listOf("master-key", "wallets")).isEmpty()) - } - - @Test - fun duplicateDriveFileNamesAreDetected() { - assertEquals( - setOf("master-key.json"), - duplicateDriveFileNames(listOf("master-key.json", "wallet-record.json", "master-key.json")), - ) - assertTrue(duplicateDriveFileNames(listOf("master-key.json", "wallet-record.json")).isEmpty()) - } - - @Test - fun backupFileLocationsRejectDuplicateJsonFiles() { - val error = - runCatching { - driveBackupFileLocations( - listOf("master-key.json", "notes.txt", "master-key.json"), - ) - }.exceptionOrNull() - - assertTrue(error is DriveHttpException) - assertEquals(HttpURLConnection.HTTP_CONFLICT, (error as DriveHttpException).statusCode) - assertEquals("duplicate google drive file: master-key.json", error.body) - } - - @Test - fun backupFileLocationsIgnoreNonJsonFilesAndApplyLocation() { - assertEquals( - listOf("wallets/wallet-record.json"), - driveBackupFileLocations( - listOf("wallet-record.json", "notes.txt"), - { fileName -> "wallets/$fileName" }, - ), - ) - } - - @Test - fun cloudBackupNamespaceValidationMatchesRustShape() { - assertTrue(isValidCloudBackupNamespaceId("0123456789abcdef0123456789abcdef")) - assertFalse(isValidCloudBackupNamespaceId("0123456789ABCDEF0123456789abcdef")) - assertFalse(isValidCloudBackupNamespaceId("../0123456789abcdef0123456789abcd")) - assertFalse(isValidCloudBackupNamespaceId("0123456789abcdef")) - } - - @Test - fun foregroundAuthorizationTimeoutMapsToAuthorizationRequired() { - val error = mapDriveListError(ForegroundAuthorizationTimeoutException("google drive authorization timed out")) - - assertTrue(error is CloudStorageException.AuthorizationRequired) - assertEquals( - "google drive authorization timed out", - (error as CloudStorageException.AuthorizationRequired).v1, - ) - } - - @Test - fun googleApiErrorsAreMappedToUnavailableWithStatus() { - val apiError = ApiException(Status(CommonStatusCodes.DEVELOPER_ERROR)) - val uploadError = mapDriveUploadError(apiError, "wallet-record") - val listError = mapDriveListError(apiError) - - assertTrue(uploadError is CloudStorageException.NotAvailable) - assertTrue(listError is CloudStorageException.NotAvailable) - assertEquals( - "google drive is unavailable: DEVELOPER_ERROR", - (uploadError as CloudStorageException.NotAvailable).v1, - ) - assertEquals( - "google drive is unavailable: DEVELOPER_ERROR", - (listError as CloudStorageException.NotAvailable).v1, - ) - } - - @Test - fun unregisteredGoogleApiErrorsPointAtOAuthSetup() { - val apiError = ApiException( - Status( - CommonStatusCodes.INTERNAL_ERROR, - "[8] Unknown error [status=UNREGISTERED_ON_API_CONSOLE].", - ), - ) - val listError = mapDriveListError(apiError) - - assertTrue(listError is CloudStorageException.NotAvailable) - assertEquals( - "google drive is unavailable: google drive OAuth client is not registered for this app", - (listError as CloudStorageException.NotAvailable).v1, - ) - } - @Test fun walletOperationErrorsUseLocationErrorId() = runBlocking { @@ -1230,402 +105,4 @@ class AndroidCloudStorageAccessTest { assertNotFoundTarget(downloadError, "wallets/wallet-record.json") assertNotFoundTarget(deleteError, "wallets/wallet-record.json") } - - @Test - fun driveHttpUploadErrorsAreMappedBeforeGenericIoErrors() { - val notFound = mapDriveUploadError(DriveHttpException(404, "missing"), "wallet-record") - val quotaExceeded = mapDriveUploadError(DriveHttpException(429, "rate limit"), "wallet-record") - val forbiddenQuota = mapDriveUploadError( - DriveHttpException(403, driveErrorBody("storageQuotaExceeded")), - "wallet-record", - ) - val forbiddenAuthorization = mapDriveUploadError( - DriveHttpException(403, driveErrorBody("insufficientFilePermissions")), - "wallet-record", - ) - val disabledApi = mapDriveUploadError( - DriveHttpException(403, disabledDriveApiBody()), - "wallet-record", - ) - val offline = mapDriveUploadError(IOException("network unavailable"), "wallet-record") - - assertTrue(notFound is CloudStorageException.NotFound) - assertTrue(quotaExceeded is CloudStorageException.QuotaExceeded) - assertTrue(forbiddenQuota is CloudStorageException.QuotaExceeded) - assertTrue(forbiddenAuthorization is CloudStorageException.AuthorizationRequired) - assertTrue(disabledApi is CloudStorageException.NotAvailable) - assertTrue(offline is CloudStorageException.Offline) - assertEquals("wallet-record", (notFound as CloudStorageException.NotFound).v1) - assertEquals( - "google drive access was rejected", - (forbiddenAuthorization as CloudStorageException.AuthorizationRequired).v1, - ) - assertEquals( - "google drive API is not enabled for this Google Cloud project", - (disabledApi as CloudStorageException.NotAvailable).v1, - ) - assertEquals("network unavailable", (offline as CloudStorageException.Offline).v1) - } - - @Test - fun driveHttpListErrorsAreMappedBeforeGenericIoErrors() { - val notFound = mapDriveListError(DriveHttpException(404, "missing")) - val quotaExceeded = mapDriveListError(DriveHttpException(429, "rate limit")) - val forbiddenQuota = mapDriveListError(DriveHttpException(403, driveErrorBody("quotaExceeded"))) - val forbiddenAuthorization = mapDriveListError( - DriveHttpException(403, driveErrorBody("insufficientFilePermissions")), - ) - val disabledApi = mapDriveListError(DriveHttpException(403, disabledDriveApiBody())) - val conflict = mapDriveListError(DriveHttpException(409, "duplicate google drive file: master-key.json")) - val offline = mapDriveListError(IOException("network unavailable")) - - assertTrue(notFound is CloudStorageException.NotFound) - assertTrue(quotaExceeded is CloudStorageException.QuotaExceeded) - assertTrue(forbiddenQuota is CloudStorageException.QuotaExceeded) - assertTrue(forbiddenAuthorization is CloudStorageException.AuthorizationRequired) - assertTrue(disabledApi is CloudStorageException.NotAvailable) - assertTrue(conflict is CloudStorageException.NotAvailable) - assertTrue(offline is CloudStorageException.Offline) - assertEquals("drive file", (notFound as CloudStorageException.NotFound).v1) - assertEquals( - "google drive access was rejected", - (forbiddenAuthorization as CloudStorageException.AuthorizationRequired).v1, - ) - assertEquals( - "google drive API is not enabled for this Google Cloud project", - (disabledApi as CloudStorageException.NotAvailable).v1, - ) - assertEquals( - "duplicate google drive file: master-key.json", - (conflict as CloudStorageException.NotAvailable).v1, - ) - assertEquals("network unavailable", (offline as CloudStorageException.Offline).v1) - } - - @Test - fun driveQuotaReasonsParsesStructuredDriveReasons() { - assertEquals( - setOf(DriveQuotaReason.StorageQuotaExceeded, DriveQuotaReason.UserRateLimitExceeded), - driveQuotaReasons( - """ - { - "error": { - "reason": "storageQuotaExceeded", - "errors": [ - { "reason": "userRateLimitExceeded" }, - { "reason": "insufficientFilePermissions" } - ] - } - } - """.trimIndent(), - ), - ) - } - - private suspend fun assertDriveAccountIdentityLookupClearsTokenAndRetries( - statusCode: Int, - body: String, - ) { - TestDriveServer().use { server -> - server.enqueue(statusCode, body) - server.enqueue( - HttpURLConnection.HTTP_OK, - """ - { - "user": { - "emailAddress": "person@example.com" - } - } - """.trimIndent(), - ) - server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") - - val authorization = SequentialDriveAuthorization(listOf("token-1", "token-2")) - val storage = AndroidCloudStorageAccess( - driveAuthorization = authorization, - accountBindingStore = TestDriveAccountBindingStore(), - driveApiEndpoints = DriveApiEndpoints( - aboutEndpoint = "${server.baseUrl}/about", - filesEndpoint = "${server.baseUrl}/files", - uploadEndpoint = "${server.baseUrl}/upload", - ), - drivePathNamesProvider = { testDrivePathNames }, - ) - - val namespacesResult = runCatching { - storage.listNamespaces(CloudAccessPolicy.SILENT) - } - assertTrue( - cloudStorageFailureMessage(namespacesResult.exceptionOrNull()), - namespacesResult.isSuccess, - ) - assertEquals(emptyList(), namespacesResult.getOrNull()) - assertEquals(listOf(false, false), authorization.accessRequests) - assertEquals(listOf("token-1"), authorization.clearedTokens) - - val requests = server.requests() - assertEquals(listOf("/about", "/about", "/files"), requests.map { it.path.substringBefore("?") }) - assertEquals( - listOf("Bearer token-1", "Bearer token-2", "Bearer token-2"), - requests.map { it.authorization }, - ) - } - } - - private fun cloudStorageFailureMessage(error: Throwable?): String = - when (error) { - null -> "listNamespaces succeeded" - is CloudStorageException.AuthorizationRequired -> - "listNamespaces failed with AuthorizationRequired: ${error.v1}" - is CloudStorageException.NotAvailable -> - "listNamespaces failed with NotAvailable: ${error.v1}" - is CloudStorageException.NotFound -> - "listNamespaces failed with NotFound: ${error.v1}" - is CloudStorageException.Offline -> - "listNamespaces failed with Offline: ${error.v1}" - is CloudStorageException.QuotaExceeded -> - "listNamespaces failed with QuotaExceeded" - is CloudStorageException.UploadFailed -> - "listNamespaces failed with UploadFailed: ${error.v1}" - is CloudStorageException.DownloadFailed -> - "listNamespaces failed with DownloadFailed: ${error.v1}" - else -> "listNamespaces failed with ${error.javaClass.name}: ${error.message}" - } - - private fun driveErrorBody(reason: String): String = - """ - { - "error": { - "errors": [ - { "reason": "$reason" } - ] - } - } - """.trimIndent() - - private fun disabledDriveApiBody(): String = - """ - { - "error": { - "message": "Google Drive API has not been used in project 738970325901 before or it is disabled.", - "errors": [ - { "reason": "accessNotConfigured" } - ], - "details": [ - { "reason": "SERVICE_DISABLED" } - ] - } - } - """.trimIndent() - - private suspend fun captureError(block: suspend () -> Unit): Throwable? = - try { - block() - null - } catch (error: Throwable) { - error - } - - private fun assertNotFoundTarget(error: Throwable?, expected: String) { - assertTrue(error is CloudStorageException.NotFound) - assertEquals(expected, (error as CloudStorageException.NotFound).v1) - } - - private class FailingDriveAuthorization( - private val error: Throwable, - ) : DriveAuthorization { - override suspend fun accessToken(interactive: Boolean): DriveAccessToken { - throw error - } - - override suspend fun clearToken(token: String) = Unit - } - - private class SequentialDriveAuthorization( - tokens: List, - ) : DriveAuthorization { - private val tokens = ArrayDeque(tokens) - val accessRequests = mutableListOf() - val clearedTokens = mutableListOf() - - override suspend fun accessToken(interactive: Boolean): DriveAccessToken { - accessRequests.add(interactive) - if (tokens.isEmpty()) { - throw AssertionError("unexpected drive access token request") - } - - return DriveAccessToken(token = tokens.removeFirst(), account = null) - } - - override suspend fun clearToken(token: String) { - clearedTokens.add(token) - } - } - - private class RecordingDriveAuthorization : DriveAuthorization { - var token = "token-1" - var account: DriveAccountIdentity? = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val accessRequests = mutableListOf() - val clearedTokens = mutableListOf() - - override suspend fun accessToken(interactive: Boolean): DriveAccessToken { - accessRequests.add(interactive) - return DriveAccessToken(token = token, account = account) - } - - override suspend fun clearToken(token: String) { - clearedTokens.add(token) - } - } - - private class BlockingClearDriveAuthorization : DriveAuthorization { - var token = "token-1" - var account = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") - val accessRequests = mutableListOf() - val clearedTokens = mutableListOf() - val clearStarted = CompletableDeferred() - val finishClear = CompletableDeferred() - - override suspend fun accessToken(interactive: Boolean): DriveAccessToken { - accessRequests.add(interactive) - return DriveAccessToken(token = token, account = account) - } - - override suspend fun clearToken(token: String) { - clearedTokens.add(token) - clearStarted.complete(Unit) - finishClear.await() - } - } - - private class TestDriveServer : AutoCloseable { - private val serverSocket = ServerSocket(0) - private val responseLock = Any() - private val requestLock = Any() - private val responses = ArrayDeque() - private val requests = mutableListOf() - - @Volatile - private var serverError: Throwable? = null - - private val thread = - Thread({ serve() }, "test-drive-server") - .apply { - isDaemon = true - start() - } - - val baseUrl = "http://127.0.0.1:${serverSocket.localPort}" - - fun enqueue(statusCode: Int, body: String) { - synchronized(responseLock) { - responses.add(TestDriveResponse(statusCode, body)) - } - } - - fun requests(): List = - synchronized(requestLock) { - requests.toList() - } - - override fun close() { - serverSocket.close() - thread.join(1_000) - serverError?.let { throw AssertionError("test drive server failed", it) } - } - - private fun serve() { - try { - while (!serverSocket.isClosed) { - val socket = - try { - serverSocket.accept() - } catch (_: SocketException) { - return - } - - socket.use { - val reader = BufferedReader(InputStreamReader(it.getInputStream(), Charsets.UTF_8)) - val requestLine = reader.readLine() ?: return@use - val authorization = readAuthorizationHeader(reader) - val requestTarget = requestLine.split(" ").getOrNull(1).orEmpty() - - synchronized(requestLock) { - requests.add(TestDriveRequest(path = requestTarget, authorization = authorization)) - } - - val response = - synchronized(responseLock) { - if (responses.isEmpty()) { - TestDriveResponse(HttpURLConnection.HTTP_INTERNAL_ERROR, "unexpected request") - } else { - responses.removeFirst() - } - } - val responseBody = response.body.toByteArray(Charsets.UTF_8) - val statusText = if (response.statusCode in 200..299) "OK" else "Error" - val headers = - "HTTP/1.1 ${response.statusCode} $statusText\r\n" + - "Content-Type: application/json\r\n" + - "Content-Length: ${responseBody.size}\r\n" + - "Connection: close\r\n" + - "\r\n" - - it.getOutputStream().use { output -> - output.write(headers.toByteArray(Charsets.UTF_8)) - output.write(responseBody) - } - } - } - } catch (error: Throwable) { - if (!serverSocket.isClosed) { - serverError = error - } - } - } - - private fun readAuthorizationHeader(reader: BufferedReader): String? { - var authorization: String? = null - while (true) { - val line = reader.readLine() ?: return authorization - if (line.isEmpty()) { - return authorization - } - - val separator = line.indexOf(':') - if (separator < 0) { - continue - } - - val name = line.substring(0, separator) - if (name.equals("Authorization", ignoreCase = true)) { - authorization = line.substring(separator + 1).trim() - } - } - } - } - - private data class TestDriveResponse( - val statusCode: Int, - val body: String, - ) - - private data class TestDriveRequest( - val path: String, - val authorization: String?, - ) - - private class TestDriveAccountBindingStore( - private var identity: DriveAccountIdentity? = null, - ) : DriveAccountBindingStore { - override fun selectedIdentity(): DriveAccountIdentity? = identity - - override fun bindIdentity(identity: DriveAccountIdentity) { - this.identity = identity - } - - override fun clearIdentity() { - identity = null - } - } } diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt index 388f9cf69..0ac5386f7 100644 --- a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt @@ -55,7 +55,7 @@ class CloudBackupRegressionHelpersTest { DeepVerificationFailure.Retry( message = "Drive unavailable", detail = null, - retryContext = null, + retryAction = null, ), ), ), diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountBindingTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountBindingTest.kt new file mode 100644 index 000000000..49fe4f768 --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountBindingTest.kt @@ -0,0 +1,110 @@ +package org.bitcoinppl.cove.cloudbackup + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveAccountBindingTest { + @Test + fun driveAccountBindingPersistsFirstAccountAndRejectsMismatch() { + val store = TestDriveAccountBindingStore() + val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val sameEmailFallback = DriveAccountIdentity(googleAccountId = null, email = "PERSON@example.com") + val mismatch = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") + + verifyDriveAccountBinding(store, first) + verifyDriveAccountBinding(store, sameEmailFallback) + + val error = runCatching { verifyDriveAccountBinding(store, mismatch) }.exceptionOrNull() + + assertTrue(error is DriveAccountBindingException.Mismatch) + } + + @Test + fun driveAccountBindingMatchesStoredGoogleIdToDrivePermissionIdByEmail() { + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com"), + ) + val fallback = DriveAccountIdentity(drivePermissionId = "permission-1", email = "PERSON@example.com") + + verifyDriveAccountBinding(store, fallback) + + assertEquals( + DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "person@example.com", + ), + store.selectedIdentity(), + ) + } + + @Test + fun driveAccountBindingValidationDoesNotPersistUnverifiedAccount() { + val store = TestDriveAccountBindingStore() + val probe = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + + verifyDriveAccountBinding(store, probe, bindIfMissing = false) + + assertEquals(null, store.selectedIdentity()) + } + + @Test + fun driveAccountBindingCanBeClearedAndRebound() { + val store = TestDriveAccountBindingStore() + val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val second = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") + + verifyDriveAccountBinding(store, first) + store.clearIdentity() + verifyDriveAccountBinding(store, second) + + assertEquals(second, store.selectedIdentity()) + } + + @Test + fun driveAccountBindingRejectsMissingIdentityWhenNoAccountIsSelected() { + val store = TestDriveAccountBindingStore() + + val error = runCatching { verifyDriveAccountBinding(store, identity = null) } + .exceptionOrNull() + + assertTrue(error is DriveAccountBindingException.MissingIdentity) + } + + @Test + fun driveAccountBindingRejectsMissingIdentityWhenAccountIsSelected() { + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com"), + ) + + val error = runCatching { verifyDriveAccountBinding(store, identity = null) } + .exceptionOrNull() + + assertTrue(error is DriveAccountBindingException.MissingIdentity) + } + + @Test + fun driveAccountBindingRejectsMissingIdentityWhenSelectedAccountCannotConstrainAuthorization() { + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(googleAccountId = "account-1", email = null), + ) + + val error = runCatching { verifyDriveAccountBinding(store, identity = null) } + .exceptionOrNull() + + assertTrue(error is DriveAccountBindingException.MissingIdentity) + } + + @Test + fun driveAccountBindingEnrichesSparseSelectedAccountAfterMatchingVerification() { + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(googleAccountId = "account-1", email = null), + ) + val verified = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + + verifyDriveAccountBinding(store, verified) + + assertEquals(verified, store.selectedIdentity()) + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountIdentityTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountIdentityTest.kt new file mode 100644 index 000000000..7809c0a09 --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAccountIdentityTest.kt @@ -0,0 +1,94 @@ +package org.bitcoinppl.cove.cloudbackup + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveAccountIdentityTest { + @Test + fun driveAccountIdentityNormalizesEmailForEqualityAndHashCode() { + val first = DriveAccountIdentity(googleAccountId = "account-1", email = " Person@Example.com ") + val second = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + assertEquals("person@example.com", first.email) + } + + @Test + fun driveAccountIdentityKeepsIdSourcesSeparate() { + val googleIdentity = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val driveIdentity = DriveAccountIdentity(drivePermissionId = "permission-1", email = "PERSON@example.com") + + assertTrue(googleIdentity.matches(driveIdentity)) + assertEquals( + DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "person@example.com", + ), + googleIdentity.verifiedMerge(driveIdentity), + ) + } + + @Test + fun driveAccountIdentityMergePoliciesHandleEmailRefreshes() { + val original = DriveAccountIdentity(googleAccountId = "account-1", email = "old@example.com") + val refreshed = DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "new@example.com", + ) + + assertEquals( + DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "old@example.com", + ), + original.withMissingFieldsFrom(refreshed), + ) + assertEquals( + DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "new@example.com", + ), + original.verifiedMerge(refreshed), + ) + } + + @Test + fun driveAccountIdentityMergePoliciesFallbackWithoutRefreshedEmail() { + val original = DriveAccountIdentity(googleAccountId = "account-1", email = "old@example.com") + val withoutEmail = DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = null, + ) + val expected = DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "old@example.com", + ) + + assertEquals(expected, original.withMissingFieldsFrom(withoutEmail)) + assertEquals(expected, original.verifiedMerge(withoutEmail)) + } + + @Test + fun driveAccountIdentityWithMissingFieldsDoesNotOverwriteExistingIds() { + val original = DriveAccountIdentity( + googleAccountId = "account-1", + drivePermissionId = "permission-1", + email = "person@example.com", + ) + val other = DriveAccountIdentity( + googleAccountId = "account-2", + drivePermissionId = "permission-2", + email = "other@example.com", + ) + + assertEquals(original, original.withMissingFieldsFrom(other)) + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAuthorizationCacheTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAuthorizationCacheTest.kt new file mode 100644 index 000000000..507b09544 --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveAuthorizationCacheTest.kt @@ -0,0 +1,260 @@ +package org.bitcoinppl.cove.cloudbackup + +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class DriveAuthorizationCacheTest { + @Test + fun cachingDriveAuthorizationReusesUpdatedTokenIdentity() = + runBlocking { + var now = 0L + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { now }, + cacheWindowMs = 1_000, + ) + val unresolved = authorization.accessToken(interactive = false) + val resolved = unresolved.copy( + account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + + authorization.updateCachedToken(resolved) + + now = 500 + assertEquals(resolved, authorization.accessToken(interactive = false)) + assertEquals(listOf(false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDropsUpdatedTokenWhenCacheKeyChanges() = + runBlocking { + var cacheKey: Any? = "account-1" + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { cacheKey }, + ) + val unresolved = authorization.accessToken(interactive = false) + val resolved = unresolved.copy( + account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + + cacheKey = "account-2" + authorization.updateCachedToken(resolved) + delegate.token = "token-2" + + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDropsUpdatedTokenWhenTokenChanges() = + runBlocking { + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + ) + val unresolved = authorization.accessToken(interactive = false) + val resolved = unresolved.copy( + token = "token-2", + account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + + authorization.updateCachedToken(resolved) + delegate.token = "token-3" + + assertEquals("token-3", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDropsUpdatedTokenAfterExpiry() = + runBlocking { + var now = 0L + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { now }, + cacheWindowMs = 1_000, + ) + val unresolved = authorization.accessToken(interactive = false) + val resolved = unresolved.copy( + account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + + now = 1_000 + authorization.updateCachedToken(resolved) + delegate.token = "token-2" + + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDropsUpdatedTokenWhenCacheKeyIsUnavailable() = + runBlocking { + var cacheKey: Any? = "account-1" + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { cacheKey }, + ) + val unresolved = authorization.accessToken(interactive = false) + val resolved = unresolved.copy( + account = DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + + cacheKey = null + authorization.updateCachedToken(resolved) + delegate.token = "token-2" + + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationReusesTokenUntilCleared() = + runBlocking { + var now = 0L + val delegate = RecordingDriveAuthorization() + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { now }, + cacheWindowMs = 1_000, + ) + + assertEquals("token-1", authorization.accessToken(interactive = false).token) + assertEquals("token-1", authorization.accessToken(interactive = true).token) + assertEquals(listOf(false), delegate.accessRequests) + + now = 500 + delegate.token = "token-2" + authorization.clearToken("other-token") + assertEquals("token-1", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false), delegate.accessRequests) + + authorization.clearToken("token-1") + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + assertEquals(listOf("other-token", "token-1"), delegate.clearedTokens) + } + + @Test + fun cachingDriveAuthorizationExpiresIdleToken() = + runBlocking { + var now = 0L + val delegate = RecordingDriveAuthorization() + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { now }, + cacheWindowMs = 1_000, + ) + + assertEquals("token-1", authorization.accessToken(interactive = false).token) + + now = 999 + assertEquals("token-1", authorization.accessToken(interactive = false).token) + + now = 2_000 + delegate.token = "token-2" + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDoesNotCacheWhenCacheKeyIsUnavailable() = + runBlocking { + val delegate = RecordingDriveAuthorization() + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { null }, + ) + + assertEquals("token-1", authorization.accessToken(interactive = false).token) + + delegate.token = "token-2" + + assertEquals("token-2", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false), delegate.accessRequests) + } + + @Test + fun cachingDriveAuthorizationDoesNotRefreshWhileClearIsRunning() = + runTest { + val delegate = BlockingClearDriveAuthorization() + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + ) + + assertEquals("token-1", authorization.accessToken(interactive = false).token) + + delegate.token = "token-2" + val clear = async { authorization.clearToken("token-1") } + delegate.clearStarted.await() + + val refresh = async { authorization.accessToken(interactive = false).token } + yield() + + assertFalse(refresh.isCompleted) + + delegate.finishClear.complete(Unit) + clear.await() + + assertEquals("token-2", refresh.await()) + assertEquals(listOf(false, false), delegate.accessRequests) + assertEquals(listOf("token-1"), delegate.clearedTokens) + } + + @Test + fun clearingDriveAccountBindingInvalidatesCachedToken() = + runBlocking { + val first = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val second = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") + val store = TestDriveAccountBindingStore(first) + val delegate = RecordingDriveAuthorization() + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { store.selectedIdentity() }, + ) + + assertEquals("token-1", authorization.accessToken(interactive = false).token) + assertEquals("token-1", authorization.accessToken(interactive = false).token) + + store.clearIdentity() + delegate.token = "token-2" + delegate.account = second + + assertEquals("token-2", authorization.accessToken(interactive = false).token) + delegate.token = "token-3" + assertEquals("token-3", authorization.accessToken(interactive = false).token) + assertEquals(listOf(false, false, false), delegate.accessRequests) + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveCloudBackupTestSupport.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveCloudBackupTestSupport.kt new file mode 100644 index 000000000..9c2f03c4d --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveCloudBackupTestSupport.kt @@ -0,0 +1,207 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.net.HttpURLConnection +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.bitcoinppl.cove_core.device.CloudStorageException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue + +internal val testDrivePathNames = DrivePathNames( + namespacesRootFolderName = "cspp-namespaces", + masterKeyFolderName = "master-key", + walletsFolderName = "wallets", + walletFilePrefix = "wallet-", +) + +internal class MockDriveServer : AutoCloseable { + private val responses = ArrayDeque() + private val server = MockWebServer() + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val response = + synchronized(responses) { + responses.removeFirstOrNull() + ?: TestDriveResponse(HttpURLConnection.HTTP_INTERNAL_ERROR, "unexpected request") + } + + return MockResponse() + .setResponseCode(response.statusCode) + .setHeader("Content-Type", "application/json") + .setBody(response.body) + } + } + server.start() + } + + val baseUrl: String = server.url("/").toString().removeSuffix("/") + + fun enqueue(statusCode: Int, body: String) { + synchronized(responses) { + responses.add(TestDriveResponse(statusCode, body)) + } + } + + fun requests(count: Int): List { + val requests = + List(count) { index -> + server.takeRequest(1, TimeUnit.SECONDS) + ?: error("missing drive request index=$index") + } + val extra = server.takeRequest(100, TimeUnit.MILLISECONDS) + check(extra == null) { "unexpected drive request ${extra?.requestLine}" } + + return requests + } + + override fun close() { + server.shutdown() + } +} + +internal data class TestDriveResponse( + val statusCode: Int, + val body: String, +) + +internal class FailingDriveAuthorization( + private val error: Throwable, +) : DriveAuthorization { + override suspend fun accessToken(interactive: Boolean): DriveAccessToken { + throw error + } + + override suspend fun clearToken(token: String) = Unit +} + +internal class SequentialDriveAuthorization( + tokens: List, +) : DriveAuthorization { + private val tokens = ArrayDeque(tokens) + val accessRequests = mutableListOf() + val clearedTokens = mutableListOf() + + override suspend fun accessToken(interactive: Boolean): DriveAccessToken { + accessRequests.add(interactive) + if (tokens.isEmpty()) { + throw AssertionError("unexpected drive access token request") + } + + return DriveAccessToken(token = tokens.removeFirst(), account = null) + } + + override suspend fun clearToken(token: String) { + clearedTokens.add(token) + } +} + +internal class RecordingDriveAuthorization : DriveAuthorization { + var token = "token-1" + var account: DriveAccountIdentity? = + DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val accessRequests = mutableListOf() + val clearedTokens = mutableListOf() + + override suspend fun accessToken(interactive: Boolean): DriveAccessToken { + accessRequests.add(interactive) + return DriveAccessToken(token = token, account = account) + } + + override suspend fun clearToken(token: String) { + clearedTokens.add(token) + } +} + +internal class BlockingClearDriveAuthorization : DriveAuthorization { + var token = "token-1" + var account = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val accessRequests = mutableListOf() + val clearedTokens = mutableListOf() + val clearStarted = CompletableDeferred() + val finishClear = CompletableDeferred() + + override suspend fun accessToken(interactive: Boolean): DriveAccessToken { + accessRequests.add(interactive) + return DriveAccessToken(token = token, account = account) + } + + override suspend fun clearToken(token: String) { + clearedTokens.add(token) + clearStarted.complete(Unit) + finishClear.await() + } +} + +internal class TestDriveAccountBindingStore( + private var identity: DriveAccountIdentity? = null, +) : DriveAccountBindingStore { + override fun selectedIdentity(): DriveAccountIdentity? = identity + + override fun bindIdentity(identity: DriveAccountIdentity) { + this.identity = identity + } + + override fun clearIdentity() { + identity = null + } +} + +internal fun driveErrorBody(reason: String): String = + """ + { + "error": { + "errors": [ + { "reason": "$reason" } + ] + } + } + """.trimIndent() + +internal fun disabledDriveApiBody(): String = + """ + { + "error": { + "message": "Google Drive API has not been used in project 738970325901 before or it is disabled.", + "errors": [ + { "reason": "accessNotConfigured" } + ], + "details": [ + { "reason": "SERVICE_DISABLED" } + ] + } + } + """.trimIndent() + +internal fun cloudStorageFailureMessage(error: Throwable?): String = + when (error) { + null -> "listNamespaces succeeded" + is CloudStorageException.AuthorizationRequired -> + "listNamespaces failed with AuthorizationRequired: ${error.v1}" + is CloudStorageException.NotAvailable -> + "listNamespaces failed with NotAvailable: ${error.v1}" + is CloudStorageException.NotFound -> + "listNamespaces failed with NotFound: ${error.v1}" + is CloudStorageException.Offline -> + "listNamespaces failed with Offline: ${error.v1}" + is CloudStorageException.QuotaExceeded -> + "listNamespaces failed with QuotaExceeded" + is CloudStorageException.UploadFailed -> + "listNamespaces failed with UploadFailed: ${error.v1}" + is CloudStorageException.DownloadFailed -> + "listNamespaces failed with DownloadFailed: ${error.v1}" + else -> "listNamespaces failed with ${error.javaClass.name}: ${error.message}" + } + +internal suspend fun captureError(block: suspend () -> Unit): Throwable? = + runCatching { block() }.exceptionOrNull() + +internal fun assertNotFoundTarget(error: Throwable?, expected: String) { + assertTrue(error is CloudStorageException.NotFound) + assertEquals(expected, (error as CloudStorageException.NotFound).v1) +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolverTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolverTest.kt new file mode 100644 index 000000000..3e75c878e --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveFolderResolverTest.kt @@ -0,0 +1,118 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.net.HttpURLConnection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveFolderResolverTest { + @Test + fun driveLocationPartsKeepsFlatFilesAtNamespaceRoot() { + assertEquals( + DriveLocationParts(parentFolders = emptyList(), fileName = "wallet-record.json"), + driveLocationParts("wallet-record.json"), + ) + } + + @Test + fun driveLocationPartsSplitsKindPrefixedFiles() { + assertEquals( + DriveLocationParts(parentFolders = listOf("wallets"), fileName = "wallet-record.json"), + driveLocationParts("wallets/wallet-record.json"), + ) + } + + @Test + fun driveLocationPartsRejectsParentTraversal() { + val error = runCatching { driveLocationParts("wallets/../wallet-record.json") } + .exceptionOrNull() + + assertTrue(error is IllegalArgumentException) + } + + @Test + fun driveLocationPartsRejectsBlankRelativePath() { + val error = runCatching { driveLocationParts("") } + .exceptionOrNull() + + assertTrue(error is IllegalArgumentException) + assertEquals("relativePath must not be blank", error?.message) + } + + @Test + fun drivePathsAcceptLegacyFlatAndKindPrefixedWalletLocations() { + assertTrue( + isWalletFileLocation( + location = "wallet-record.json", + walletFilePrefix = "wallet-", + walletsFolderName = "wallets", + ), + ) + assertTrue( + isWalletFileLocation( + location = "wallets/wallet-record.json", + walletFilePrefix = "wallet-", + walletsFolderName = "wallets", + ), + ) + assertFalse( + isWalletFileLocation( + location = "master-key/wallet-record.json", + walletFilePrefix = "wallet-", + walletsFolderName = "wallets", + ), + ) + } + + @Test + fun duplicateDriveFolderNamesAreDetected() { + assertEquals( + setOf("wallets"), + duplicateDriveFolderNames(listOf("master-key", "wallets", "wallets")), + ) + assertTrue(duplicateDriveFolderNames(listOf("master-key", "wallets")).isEmpty()) + } + + @Test + fun duplicateDriveFileNamesAreDetected() { + assertEquals( + setOf("master-key.json"), + duplicateDriveFileNames(listOf("master-key.json", "wallet-record.json", "master-key.json")), + ) + assertTrue(duplicateDriveFileNames(listOf("master-key.json", "wallet-record.json")).isEmpty()) + } + + @Test + fun backupFileLocationsRejectDuplicateJsonFiles() { + val error = + runCatching { + driveBackupFileLocations( + listOf("master-key.json", "notes.txt", "master-key.json"), + ) + }.exceptionOrNull() + + assertTrue(error is DriveHttpException) + assertEquals(HttpURLConnection.HTTP_CONFLICT, (error as DriveHttpException).statusCode) + assertEquals("duplicate google drive file: master-key.json", error.body) + } + + @Test + fun backupFileLocationsIgnoreNonJsonFilesAndApplyLocation() { + assertEquals( + listOf("wallets/wallet-record.json"), + driveBackupFileLocations( + listOf("wallet-record.json", "notes.txt"), + { fileName -> "wallets/$fileName" }, + ), + ) + } + + @Test + fun cloudBackupNamespaceValidationMatchesRustShape() { + assertTrue(isValidCloudBackupNamespaceId("0123456789abcdef0123456789abcdef")) + assertFalse(isValidCloudBackupNamespaceId("0123456789ABCDEF0123456789abcdef")) + assertFalse(isValidCloudBackupNamespaceId("../0123456789abcdef0123456789abcd")) + assertFalse(isValidCloudBackupNamespaceId("0123456789abcdef")) + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClientTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClientTest.kt new file mode 100644 index 000000000..b89372195 --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveHttpClientTest.kt @@ -0,0 +1,260 @@ +package org.bitcoinppl.cove.cloudbackup + +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.android.gms.common.api.Status +import java.io.IOException +import java.net.HttpURLConnection +import org.bitcoinppl.cove_core.device.CloudStorageException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveHttpClientTest { + @Test + fun createUploadMetadataIncludesParents() { + val metadata = createUploadMetadata(fileName = "wallet-record.json", parentId = "folder-123") + + assertEquals("wallet-record.json", metadata.name) + assertEquals(listOf("folder-123"), metadata.parents) + + val json = metadata.toJson() + assertEquals("wallet-record.json", json.getString("name")) + assertEquals("folder-123", json.getJSONArray("parents").getString(0)) + } + + @Test + fun overwriteUploadMetadataOmitsParents() { + val metadata = overwriteUploadMetadata(fileName = "wallet-record.json") + + assertEquals("wallet-record.json", metadata.name) + assertEquals(emptyList(), metadata.parents) + + val json = metadata.toJson() + assertEquals("wallet-record.json", json.getString("name")) + assertFalse(json.has("parents")) + } + + @Test + fun driveAboutResponseProvidesAccountIdentity() { + val response = + JSONObject( + """ + { + "user": { + "emailAddress": "Person@Example.com", + "permissionId": "account-1" + } + } + """.trimIndent(), + ) + + assertEquals( + DriveAccountIdentity(drivePermissionId = "account-1", email = "person@example.com"), + driveAccountIdentityFromAboutResponse(response), + ) + } + + @Test + fun driveAboutResponseOmitsBlankIdentity() { + assertEquals(null, driveAccountIdentityFromAboutResponse(JSONObject("{}"))) + assertEquals( + null, + driveAccountIdentityFromAboutResponse( + JSONObject( + """ + { + "user": { + "emailAddress": " ", + "permissionId": "" + } + } + """.trimIndent(), + ), + ), + ) + } + + @Test + fun driveApiUrlEncodesQueryParameters() { + assertEquals( + "https://example.test/files?q=name%20%3D%20%27a%20b%2Bc%27&email=a%2Bb%40example.com", + driveApiUrl( + "https://example.test/files", + listOf("q" to "name = 'a b+c'", "email" to "a+b@example.com"), + ), + ) + } + + @Test + fun driveApiUrlKeepsExistingQueryAndFragmentOrder() { + assertEquals( + "https://example.test/files?alt=json&fields=files%28id%2Cname%29#metadata", + driveApiUrl( + "https://example.test/files?alt=json#metadata", + listOf("fields" to "files(id,name)"), + ), + ) + assertEquals( + "https://example.test/files#metadata", + driveApiUrl("https://example.test/files#metadata", emptyList()), + ) + } + + @Test + fun authorizationRequiredErrorsPreserveMessagesAcrossOperations() { + val authorizationError = AuthorizationRequiredException("google drive authorization was cancelled") + val uploadError = mapDriveUploadError(authorizationError, "wallet-record") + val listError = mapDriveListError(authorizationError) + + assertTrue(uploadError is CloudStorageException.AuthorizationRequired) + assertTrue(listError is CloudStorageException.AuthorizationRequired) + assertEquals( + "google drive authorization was cancelled", + (uploadError as CloudStorageException.AuthorizationRequired).v1, + ) + assertEquals( + "google drive authorization was cancelled", + (listError as CloudStorageException.AuthorizationRequired).v1, + ) + } + + @Test + fun foregroundAuthorizationTimeoutMapsToAuthorizationRequired() { + val error = mapDriveListError(ForegroundAuthorizationTimeoutException("google drive authorization timed out")) + + assertTrue(error is CloudStorageException.AuthorizationRequired) + assertEquals( + "google drive authorization timed out", + (error as CloudStorageException.AuthorizationRequired).v1, + ) + } + + @Test + fun googleApiErrorsAreMappedToUnavailableWithStatus() { + val apiError = ApiException(Status(CommonStatusCodes.DEVELOPER_ERROR)) + val uploadError = mapDriveUploadError(apiError, "wallet-record") + val listError = mapDriveListError(apiError) + + assertTrue(uploadError is CloudStorageException.NotAvailable) + assertTrue(listError is CloudStorageException.NotAvailable) + assertEquals( + "google drive is unavailable: DEVELOPER_ERROR", + (uploadError as CloudStorageException.NotAvailable).v1, + ) + assertEquals( + "google drive is unavailable: DEVELOPER_ERROR", + (listError as CloudStorageException.NotAvailable).v1, + ) + } + + @Test + fun unregisteredGoogleApiErrorsPointAtOAuthSetup() { + val apiError = ApiException( + Status( + CommonStatusCodes.INTERNAL_ERROR, + "[8] Unknown error [status=UNREGISTERED_ON_API_CONSOLE].", + ), + ) + val listError = mapDriveListError(apiError) + + assertTrue(listError is CloudStorageException.NotAvailable) + assertEquals( + "google drive is unavailable: google drive OAuth client is not registered for this app", + (listError as CloudStorageException.NotAvailable).v1, + ) + } + + @Test + fun driveHttpUploadErrorsAreMappedBeforeGenericIoErrors() { + val notFound = mapDriveUploadError(DriveHttpException(404, "missing"), "wallet-record") + val quotaExceeded = mapDriveUploadError(DriveHttpException(429, "rate limit"), "wallet-record") + val forbiddenQuota = mapDriveUploadError( + DriveHttpException(403, driveErrorBody("storageQuotaExceeded")), + "wallet-record", + ) + val forbiddenAuthorization = mapDriveUploadError( + DriveHttpException(403, driveErrorBody("insufficientFilePermissions")), + "wallet-record", + ) + val disabledApi = mapDriveUploadError( + DriveHttpException(403, disabledDriveApiBody()), + "wallet-record", + ) + val offline = mapDriveUploadError(IOException("network unavailable"), "wallet-record") + + assertTrue(notFound is CloudStorageException.NotFound) + assertTrue(quotaExceeded is CloudStorageException.QuotaExceeded) + assertTrue(forbiddenQuota is CloudStorageException.QuotaExceeded) + assertTrue(forbiddenAuthorization is CloudStorageException.AuthorizationRequired) + assertTrue(disabledApi is CloudStorageException.NotAvailable) + assertTrue(offline is CloudStorageException.Offline) + assertEquals("wallet-record", (notFound as CloudStorageException.NotFound).v1) + assertEquals( + "google drive access was rejected", + (forbiddenAuthorization as CloudStorageException.AuthorizationRequired).v1, + ) + assertEquals( + "google drive API is not enabled for this Google Cloud project", + (disabledApi as CloudStorageException.NotAvailable).v1, + ) + assertEquals("network unavailable", (offline as CloudStorageException.Offline).v1) + } + + @Test + fun driveHttpListErrorsAreMappedBeforeGenericIoErrors() { + val notFound = mapDriveListError(DriveHttpException(404, "missing")) + val quotaExceeded = mapDriveListError(DriveHttpException(429, "rate limit")) + val forbiddenQuota = mapDriveListError(DriveHttpException(403, driveErrorBody("quotaExceeded"))) + val forbiddenAuthorization = mapDriveListError( + DriveHttpException(403, driveErrorBody("insufficientFilePermissions")), + ) + val disabledApi = mapDriveListError(DriveHttpException(403, disabledDriveApiBody())) + val conflict = mapDriveListError(DriveHttpException(409, "duplicate google drive file: master-key.json")) + val offline = mapDriveListError(IOException("network unavailable")) + + assertTrue(notFound is CloudStorageException.NotFound) + assertTrue(quotaExceeded is CloudStorageException.QuotaExceeded) + assertTrue(forbiddenQuota is CloudStorageException.QuotaExceeded) + assertTrue(forbiddenAuthorization is CloudStorageException.AuthorizationRequired) + assertTrue(disabledApi is CloudStorageException.NotAvailable) + assertTrue(conflict is CloudStorageException.NotAvailable) + assertTrue(offline is CloudStorageException.Offline) + assertEquals("drive file", (notFound as CloudStorageException.NotFound).v1) + assertEquals( + "google drive access was rejected", + (forbiddenAuthorization as CloudStorageException.AuthorizationRequired).v1, + ) + assertEquals( + "google drive API is not enabled for this Google Cloud project", + (disabledApi as CloudStorageException.NotAvailable).v1, + ) + assertEquals( + "duplicate google drive file: master-key.json", + (conflict as CloudStorageException.NotAvailable).v1, + ) + assertEquals("network unavailable", (offline as CloudStorageException.Offline).v1) + } + + @Test + fun driveQuotaReasonsParsesStructuredDriveReasons() { + assertEquals( + setOf(DriveQuotaReason.StorageQuotaExceeded, DriveQuotaReason.UserRateLimitExceeded), + driveQuotaReasons( + """ + { + "error": { + "reason": "storageQuotaExceeded", + "errors": [ + { "reason": "userRateLimitExceeded" }, + { "reason": "insufficientFilePermissions" } + ] + } + } + """.trimIndent(), + ), + ) + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProviderTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProviderTest.kt new file mode 100644 index 000000000..f4052b03c --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/DriveTokenProviderTest.kt @@ -0,0 +1,369 @@ +package org.bitcoinppl.cove.cloudbackup + +import java.net.HttpURLConnection +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.RecordedRequest +import org.bitcoinppl.cove_core.device.CloudAccessPolicy +import org.bitcoinppl.cove_core.device.CloudStorageException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveTokenProviderTest { + @Test + fun tokenAcquisitionFailuresPreserveAuthorizationMessages() = + runBlocking { + val storage = + AndroidCloudStorageAccess( + FailingDriveAuthorization(AuthorizationRequiredException("consent required")), + TestDriveAccountBindingStore(), + ) + + val error = captureError { + storage.listNamespaces(CloudAccessPolicy.SILENT) + } + + assertTrue(error is CloudStorageException.AuthorizationRequired) + assertEquals( + "consent required", + (error as CloudStorageException.AuthorizationRequired).v1, + ) + } + + @Test + fun unauthorizedDriveAccountIdentityLookupClearsTokenAndRetries() = + runBlocking { + assertDriveAccountIdentityLookupClearsTokenAndRetries( + statusCode = HttpURLConnection.HTTP_UNAUTHORIZED, + body = "expired token", + ) + } + + @Test + fun rejectedDriveAccountIdentityLookupClearsTokenAndRetries() = + runBlocking { + assertDriveAccountIdentityLookupClearsTokenAndRetries( + statusCode = HttpURLConnection.HTTP_FORBIDDEN, + body = driveErrorBody("insufficientPermissions"), + ) + } + + @Test + fun repeatedRejectedDriveAccountIdentityLookupIdentifiesVerificationFailure() = + runBlocking { + MockDriveServer().use { server -> + server.enqueue(HttpURLConnection.HTTP_FORBIDDEN, driveErrorBody("insufficientPermissions")) + server.enqueue(HttpURLConnection.HTTP_FORBIDDEN, driveErrorBody("insufficientPermissions")) + + val authorization = SequentialDriveAuthorization(listOf("token-1", "token-2")) + val storage = storageFor(server, authorization) + + val error = captureError { + storage.listNamespaces(CloudAccessPolicy.SILENT) + } + + assertTrue(error is CloudStorageException.AuthorizationRequired) + assertEquals( + "google drive identity verification was rejected", + (error as CloudStorageException.AuthorizationRequired).v1, + ) + assertEquals(listOf(false, false), authorization.accessRequests) + assertEquals(listOf("token-1", "token-2"), authorization.clearedTokens) + assertEquals(listOf("/about", "/about"), requestPaths(server.requests(2))) + } + } + + @Test + fun resolvedDriveAccountIdentityIsCachedAcrossOperations() = + runBlocking { + MockDriveServer().use { server -> + enqueueIdentity(server) + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + ) + val storage = storageFor(server, authorization) + + assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) + assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) + assertEquals(listOf(false), delegate.accessRequests) + + val requests = server.requests(3) + assertEquals(listOf("/about", "/files", "/files"), requestPaths(requests)) + assertEquals(List(3) { "Bearer token-1" }, authorizations(requests)) + } + } + + @Test + fun sparseDriveAccountIdentityIsResolvedBeforeVerification() = + runBlocking { + MockDriveServer().use { server -> + enqueueIdentity(server) + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(googleAccountId = null, email = "person@example.com"), + ) + val delegate = RecordingDriveAuthorization().apply { + account = DriveAccountIdentity(googleAccountId = "account-1", email = null) + } + val authorization = cachingAuthorization(delegate, store) + val storage = storageFor(server, authorization, store) + + val firstResult = runCatching { storage.listNamespaces(CloudAccessPolicy.SILENT) } + val secondResult = runCatching { storage.listNamespaces(CloudAccessPolicy.SILENT) } + + assertSuccessfulEmptyList(firstResult) + assertSuccessfulEmptyList(secondResult) + assertEquals(listOf(false), delegate.accessRequests) + assertTrue(delegate.clearedTokens.isEmpty()) + + val requests = server.requests(3) + assertEquals(listOf("/about", "/files", "/files"), requestPaths(requests)) + assertEquals(List(3) { "Bearer token-1" }, authorizations(requests)) + } + } + + @Test + fun storedDrivePermissionIdIsResolvedBeforeBindingVerification() = + runBlocking { + MockDriveServer().use { server -> + enqueuePermissionOnlyIdentity(server) + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + + val store = TestDriveAccountBindingStore( + DriveAccountIdentity(drivePermissionId = "permission-1", email = null), + ) + val delegate = RecordingDriveAuthorization().apply { + account = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + } + val storage = storageFor(server, cachingAuthorization(delegate, store), store) + + val result = runCatching { storage.listNamespaces(CloudAccessPolicy.SILENT) } + + assertSuccessfulEmptyList(result) + assertEquals(listOf(false), delegate.accessRequests) + assertTrue(delegate.clearedTokens.isEmpty()) + + val requests = server.requests(2) + assertEquals(listOf("/about", "/files"), requestPaths(requests)) + assertTrue(requests.first().path.orEmpty().contains("permissionId")) + assertEquals(List(2) { "Bearer token-1" }, authorizations(requests)) + } + } + + @Test + fun constrainedAuthorizationWithoutIdentityIsVerifiedThroughDriveAbout() = + runBlocking { + MockDriveServer().use { server -> + enqueueOtherIdentity(server) + + val selected = DriveAccountIdentity(googleAccountId = null, email = "person@example.com") + val authorization = RecordingDriveAuthorization().apply { + account = null + } + val storage = storageFor(server, authorization, TestDriveAccountBindingStore(selected)) + + val error = captureError { + storage.listNamespaces(CloudAccessPolicy.SILENT) + } + + assertTrue(error is CloudStorageException.AuthorizationRequired) + assertEquals( + "google drive account does not match the account selected for Cloud Backup", + (error as CloudStorageException.AuthorizationRequired).v1, + ) + assertEquals(listOf("token-1"), authorization.clearedTokens) + + val requests = server.requests(1) + assertEquals(listOf("/about"), requestPaths(requests)) + assertTrue(requests.single().path.orEmpty().contains("permissionId")) + } + } + + @Test + fun unboundReadOnlyOperationsReuseResolvedDriveAccountIdentity() = + runBlocking { + MockDriveServer().use { server -> + enqueueIdentity(server) + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + + val store = TestDriveAccountBindingStore() + val delegate = RecordingDriveAuthorization().apply { + account = null + } + val authorization = CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { driveAccountTokenCacheKey(store) }, + ) + val storage = storageFor(server, authorization, store) + + assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) + assertEquals(emptyList(), storage.listNamespaces(CloudAccessPolicy.SILENT)) + assertEquals(null, store.selectedIdentity()) + assertEquals(listOf(false), delegate.accessRequests) + + val requests = server.requests(3) + assertEquals(listOf("/about", "/files", "/files"), requestPaths(requests)) + assertEquals(List(3) { "Bearer token-1" }, authorizations(requests)) + } + } + + @Test + fun cachedWrongDriveAccountTokenIsClearedAfterMismatch() = + runBlocking { + val selected = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + val actual = DriveAccountIdentity(googleAccountId = "account-2", email = "other@example.com") + val store = TestDriveAccountBindingStore(selected) + val delegate = RecordingDriveAuthorization().apply { + account = actual + } + val storage = AndroidCloudStorageAccess(cachingAuthorization(delegate, store), store) + + repeat(2) { + val error = captureError { + storage.listNamespaces(CloudAccessPolicy.SILENT) + } + + assertTrue(error is CloudStorageException.AuthorizationRequired) + } + + assertEquals(listOf(false, false), delegate.accessRequests) + assertEquals(listOf("token-1", "token-1"), delegate.clearedTokens) + } + + private suspend fun assertDriveAccountIdentityLookupClearsTokenAndRetries( + statusCode: Int, + body: String, + ) { + MockDriveServer().use { server -> + server.enqueue(statusCode, body) + enqueueEmailOnlyIdentity(server) + server.enqueue(HttpURLConnection.HTTP_OK, """{"files":[]}""") + + val authorization = SequentialDriveAuthorization(listOf("token-1", "token-2")) + val storage = storageFor(server, authorization) + + val namespacesResult = runCatching { + storage.listNamespaces(CloudAccessPolicy.SILENT) + } + + assertSuccessfulEmptyList(namespacesResult) + assertEquals(listOf(false, false), authorization.accessRequests) + assertEquals(listOf("token-1"), authorization.clearedTokens) + + val requests = server.requests(3) + assertEquals(listOf("/about", "/about", "/files"), requestPaths(requests)) + assertEquals( + listOf("Bearer token-1", "Bearer token-2", "Bearer token-2"), + authorizations(requests), + ) + } + } + + private fun storageFor( + server: MockDriveServer, + authorization: DriveAuthorization, + store: TestDriveAccountBindingStore = TestDriveAccountBindingStore(), + ): AndroidCloudStorageAccess = + AndroidCloudStorageAccess( + driveAuthorization = authorization, + accountBindingStore = store, + driveApiEndpoints = DriveApiEndpoints( + aboutEndpoint = "${server.baseUrl}/about", + filesEndpoint = "${server.baseUrl}/files", + uploadEndpoint = "${server.baseUrl}/upload", + ), + drivePathNamesProvider = { testDrivePathNames }, + ) + + private fun cachingAuthorization( + delegate: RecordingDriveAuthorization, + store: TestDriveAccountBindingStore, + ): CachingDriveAuthorization = + CachingDriveAuthorization( + delegate = delegate, + elapsedRealtime = { 0 }, + cacheWindowMs = 1_000, + cacheKey = { store.selectedIdentity() }, + ) + + private fun assertSuccessfulEmptyList(result: Result>) { + assertTrue( + cloudStorageFailureMessage(result.exceptionOrNull()), + result.isSuccess, + ) + assertEquals(emptyList(), result.getOrNull()) + } + + private fun requestPaths(requests: List): List = + requests.map { it.path.orEmpty().substringBefore("?") } + + private fun authorizations(requests: List): List = + requests.map { it.getHeader("Authorization") } + + private fun enqueueIdentity(server: MockDriveServer) { + server.enqueue( + HttpURLConnection.HTTP_OK, + """ + { + "user": { + "emailAddress": "person@example.com", + "permissionId": "account-1" + } + } + """.trimIndent(), + ) + } + + private fun enqueueEmailOnlyIdentity(server: MockDriveServer) { + server.enqueue( + HttpURLConnection.HTTP_OK, + """ + { + "user": { + "emailAddress": "person@example.com" + } + } + """.trimIndent(), + ) + } + + private fun enqueuePermissionOnlyIdentity(server: MockDriveServer) { + server.enqueue( + HttpURLConnection.HTTP_OK, + """ + { + "user": { + "permissionId": "permission-1" + } + } + """.trimIndent(), + ) + } + + private fun enqueueOtherIdentity(server: MockDriveServer) { + server.enqueue( + HttpURLConnection.HTTP_OK, + """ + { + "user": { + "emailAddress": "other@example.com", + "permissionId": "account-2" + } + } + """.trimIndent(), + ) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d97540327..16781ab8b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") version "9.0.1" apply false id("com.android.compose.screenshot") version "0.0.1-alpha15" apply false + id("dev.detekt") version "2.0.0-alpha.3" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false } diff --git a/android/detekt-baseline.xml b/android/detekt-baseline.xml new file mode 100644 index 000000000..9d6f86a2b --- /dev/null +++ b/android/detekt-baseline.xml @@ -0,0 +1,1496 @@ + + + + + AbstractClassCanBeInterface:AppSheetState.kt:AppSheetState$AppSheetState + AbstractClassCanBeInterface:ChangePinView.kt:ChangePinState$ChangePinState + AbstractClassCanBeInterface:CloudBackupPresentationHost.kt:CloudBackupRootPresentation$CloudBackupRootPresentation + AbstractClassCanBeInterface:CloudBackupPresentationHost.kt:CloudBackupVerificationFeedback$CloudBackupVerificationFeedback + AbstractClassCanBeInterface:DecoyPinView.kt:DecoyPinState$DecoyPinState + AbstractClassCanBeInterface:MoreInfoPopover.kt:ExportType$ExportType + AbstractClassCanBeInterface:NewPinView.kt:NewPinState$NewPinState + AbstractClassCanBeInterface:NfcReader.kt:NfcScanResult$NfcScanResult + AbstractClassCanBeInterface:NfcWriter.kt:NfcWriteResult$NfcWriteResult + AbstractClassCanBeInterface:StartupRecovery.kt:BootstrapFailure$BootstrapFailure + AbstractClassCanBeInterface:StartupRecovery.kt:CatastrophicCloudRestoreCheck$CatastrophicCloudRestoreCheck + AbstractClassCanBeInterface:WipeDataPinView.kt:WipeDataPinState$WipeDataPinState + ComplexCondition:DriveAuthorizationHelper.kt:CachingDriveAuthorization$currentCacheKey == null || cached.cacheKey != currentCacheKey || cached.expiresAtMs <= elapsedRealtime() || cached.token.token != accessToken.token + CyclomaticComplexMethod:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutSettingsScreen + CyclomaticComplexMethod:AndroidCloudStorageSupport.kt:internal fun mapDriveListError: CloudStorageException + CyclomaticComplexMethod:AndroidCloudStorageSupport.kt:internal fun mapDriveUploadError: CloudStorageException + CyclomaticComplexMethod:AppManager.kt:AppManager$override fun reconcile + CyclomaticComplexMethod:BackupExportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupExportScreen + CyclomaticComplexMethod:BackupImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupImportScreen + CyclomaticComplexMethod:BackupVerifyScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupVerifyScreen + CyclomaticComplexMethod:BlockExplorerSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockExplorerSettingsScreen + CyclomaticComplexMethod:CloudBackupDetailContent.kt:@Composable internal fun CloudBackupDetailContent + CyclomaticComplexMethod:CloudBackupPresentationHost.kt:@Composable fun CloudBackupPresentationHost + CyclomaticComplexMethod:CloudOnlySection.kt:@Composable internal fun CloudOnlySection + CyclomaticComplexMethod:CoinControlCustomAmountSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlCustomAmountSheet + CyclomaticComplexMethod:CustomFeeRateSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomFeeRateSheet + CyclomaticComplexMethod:HotWalletImportCard.kt:@Composable private fun WordInputField + CyclomaticComplexMethod:HotWalletImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletImportScreen + CyclomaticComplexMethod:LockView.kt:@Composable fun LockView + CyclomaticComplexMethod:MainActivity.kt:MainActivity$override fun onCreate + CyclomaticComplexMethod:MainActivityGlobalAlerts.kt:@Composable private fun GlobalAlertDialog + CyclomaticComplexMethod:MainSettingsScreen.kt:@Composable private fun cloudBackupSettingsSubtitle: String + CyclomaticComplexMethod:MainSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainSettingsScreen + CyclomaticComplexMethod:MainSettingsSecuritySection.kt:@Composable internal fun SecuritySection + CyclomaticComplexMethod:MainSettingsSecuritySection.kt:@Composable private fun SecuritySheetDialog + CyclomaticComplexMethod:MoreInfoPopover.kt:@Composable fun rememberWalletExportLaunchers: WalletExportLaunchers + CyclomaticComplexMethod:NewWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewWalletSelectScreen + CyclomaticComplexMethod:NfcReader.kt:NfcReader$private fun processNdefMessage + CyclomaticComplexMethod:NfcScanSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcScanSheet + CyclomaticComplexMethod:NodeSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NodeSettingsScreen + CyclomaticComplexMethod:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingCloudBackupDetailsStepView + CyclomaticComplexMethod:OnboardingContainer.kt:@Composable internal fun OnboardingContainer + CyclomaticComplexMethod:OtherBackupsSection.kt:@Composable internal fun OtherBackupsSection + CyclomaticComplexMethod:QrCodeScanView.kt:@Composable @androidx.camera.core.ExperimentalGetImage private fun QrScannerContent + CyclomaticComplexMethod:QrExportView.kt:@Composable fun QrExportView + CyclomaticComplexMethod:SelectedWalletContainer.kt:@Composable fun SelectedWalletContainer + CyclomaticComplexMethod:SelectedWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SelectedWalletScreen + CyclomaticComplexMethod:SendFlowConfirmScreen.kt:@Composable private fun SwipeToSendStub + CyclomaticComplexMethod:SendFlowContainer.kt:@Composable fun SendFlowContainer + CyclomaticComplexMethod:SendFlowContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFlowRouteToScreen + CyclomaticComplexMethod:SendFlowManager.kt:SendFlowManager$private fun apply + CyclomaticComplexMethod:SendFlowPresenter.kt:SendFlowPresenter$private fun errorAlertMessage: String + CyclomaticComplexMethod:SendFlowSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowSetAmountScreen + CyclomaticComplexMethod:SidebarContainer.kt:@Composable fun SidebarContainer + CyclomaticComplexMethod:SidebarView.kt:private fun WalletColor.toComposeColor: Color + CyclomaticComplexMethod:TapCardNfcManager.kt:TapCardNfcManager$suspend fun <T> performTapSignerCmd: Pair<T, TapSignerResponse?> + CyclomaticComplexMethod:TapSignerContainer.kt:@Composable fun TapSignerContainer + CyclomaticComplexMethod:TransactionDetailsContainer.kt:@Composable fun TransactionDetailsContainer + CyclomaticComplexMethod:TransactionDetailsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen + CyclomaticComplexMethod:TransactionLabelView.kt:@Composable fun TransactionLabelView + CyclomaticComplexMethod:TransactionsCardView.kt:@Composable fun TransactionsCardView + CyclomaticComplexMethod:TransactionsRows.kt:@Composable internal fun ConfirmedTransactionWidget + CyclomaticComplexMethod:TransactionsRows.kt:@Composable internal fun UnconfirmedTransactionWidget + CyclomaticComplexMethod:VerifyWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyWordsScreen + CyclomaticComplexMethod:WalletColorExt.kt:fun WalletColor.toComposeColor: Color + CyclomaticComplexMethod:WalletManager.kt:WalletManager$private fun apply + CyclomaticComplexMethod:WalletSettingsContainer.kt:@Composable fun WalletSettingsContainer + CyclomaticComplexMethod:WalletSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletSettingsScreen + CyclomaticComplexMethod:WalletSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WalletSheetsHost + DestructuringDeclarationWithTooManyEntries:CloudBackupWalletRows.kt:val (label, color, fill, border, icon) = when (status) { CloudBackupWalletStatus.DIRTY -> StatusBadgeStyle("Dirty", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.WarningAmber) CloudBackupWalletStatus.UPLOADING, CloudBackupWalletStatus.UPLOADED_PENDING_CONFIRMATION, -> StatusBadgeStyle("Syncing", colors.cloudBlue, colors.cloudBlueFill, colors.cloudBlue.copy(alpha = 0.48f), Icons.Default.Refresh) CloudBackupWalletStatus.CONFIRMED -> StatusBadgeStyle("Confirmed", colors.success, colors.successFill, colors.successBorder, Icons.Default.Check) CloudBackupWalletStatus.FAILED -> StatusBadgeStyle("Failed", colors.danger, colors.dangerFill, colors.dangerBorder, Icons.Default.WarningAmber) CloudBackupWalletStatus.DELETED_FROM_DEVICE -> StatusBadgeStyle("Not on device", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.DoNotDisturbOn) CloudBackupWalletStatus.UNSUPPORTED_VERSION -> StatusBadgeStyle("Unsupported", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.WarningAmber) CloudBackupWalletStatus.REMOTE_STATE_UNKNOWN -> StatusBadgeStyle("Unknown", colors.secondaryText, colors.cardFill, colors.cardBorder, Icons.Default.WarningAmber) } + ForbiddenComment:CoveApplication.kt:// TODO: make these configurable and store in database + ForbiddenComment:SelectedWalletContainer.kt:// TODO: get from theme + ForbiddenComment:TransactionDetailsWidget.kt:/* TODO: show fee info */ + FunctionNaming:AboutDebugSection.kt:@Composable internal fun AboutDebugSection + FunctionNaming:AboutDebugSection.kt:@Composable private fun AboutActionRow + FunctionNaming:AboutSettingsScreen.kt:@Composable internal fun AboutSettingsPreviewContent + FunctionNaming:AboutSettingsScreen.kt:@Composable private fun AboutRow + FunctionNaming:AboutSettingsScreen.kt:@Composable private fun DebugRow + FunctionNaming:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutSettingsScreen + FunctionNaming:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AboutSettingsContent + FunctionNaming:AboutSettingsScreen.kt:@Preview( name = "About Settings", showSystemUi = true, widthDp = 393, heightDp = 852, ) @Composable private fun AboutSettingsScreenPreview + FunctionNaming:AdvancedDetailsBottomSheet.kt:@Composable fun AdvancedDetailsBottomSheet + FunctionNaming:AdvancedDetailsBottomSheet.kt:@Composable private fun SentToSelfCard + FunctionNaming:AdvancedDetailsBottomSheet.kt:@Composable private fun UtxoCard + FunctionNaming:AdvancedDetailsBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun AdvancedDetailsBottomSheetPreview + FunctionNaming:AdvancedDetailsBottomSheet.kt:@Preview(showBackground = true) @Composable private fun AdvancedDetailsContentPreview + FunctionNaming:AppearanceSettingsScreen.kt:@Composable private fun ColorSchemeRow + FunctionNaming:AppearanceSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppearanceSettingsScreen + FunctionNaming:AsyncView.kt:@Composable fun <T> AsyncView + FunctionNaming:AsyncView.kt:@Composable fun AsyncText + FunctionNaming:AutoSizeText.kt:@Composable fun AutoSizeText + FunctionNaming:AutoSizeText.kt:@Composable fun AutoSizeTextField + FunctionNaming:AutoSizeText.kt:@Composable fun BalanceAutoSizeText + FunctionNaming:BackupExportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupExportScreen + FunctionNaming:BackupImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupImportScreen + FunctionNaming:BackupVerifyScreen.kt:@Composable fun MetadataItem + FunctionNaming:BackupVerifyScreen.kt:@Composable fun VerifyResultContent + FunctionNaming:BackupVerifyScreen.kt:@Composable fun WalletSummaryCard + FunctionNaming:BackupVerifyScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupVerifyScreen + FunctionNaming:BitcoinShieldIcon.kt:@Composable fun BitcoinShieldIcon + FunctionNaming:BitcoinShieldIcon.kt:@Preview(showBackground = true, backgroundColor = 0xFF1A1A2E) @Composable private fun BitcoinShieldIconPreview + FunctionNaming:BlockExplorerSettingsScreen.kt:@Composable private fun BlockExplorerOptionRow + FunctionNaming:BlockExplorerSettingsScreen.kt:@Composable private fun NetworkPickerRow + FunctionNaming:BlockExplorerSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockExplorerSettingsScreen + FunctionNaming:ChangePinView.kt:@Composable fun ChangePinView + FunctionNaming:ChoiceDialog.kt:@Composable internal fun ChoiceAlertDialog + FunctionNaming:ChoiceDialog.kt:@Composable internal fun ChoiceAlertDialogNoCancelPreviewContent + FunctionNaming:ChoiceDialog.kt:@Composable internal fun ChoiceAlertDialogPreviewContent + FunctionNaming:ChoiceDialog.kt:@Composable private fun DialogChoiceRow + FunctionNaming:ChoiceDialog.kt:@Preview(showSystemUi = true, widthDp = 393, heightDp = 852) @Composable private fun ChoiceAlertDialogNoCancelPreview + FunctionNaming:ChoiceDialog.kt:@Preview(showSystemUi = true, widthDp = 393, heightDp = 852) @Composable private fun ChoiceAlertDialogPreview + FunctionNaming:ChooseWalletTypeSheet.kt:@Composable private fun ChooseWalletTypeSheetContent + FunctionNaming:ChooseWalletTypeSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChooseWalletTypeSheet + FunctionNaming:ChooseWalletTypeSheet.kt:@Preview(showBackground = true) @Composable private fun ChooseWalletTypeSheetContentPreview + FunctionNaming:CloudBackupDetailContent.kt:@Composable internal fun CloudBackupDetailContent + FunctionNaming:CloudBackupDetailContent.kt:@Composable private fun DetailFormContent + FunctionNaming:CloudBackupDetailContent.kt:@Composable private fun MissingPasskeyContent + FunctionNaming:CloudBackupDetailContent.kt:@Composable private fun PendingUploadConfirmationStatus + FunctionNaming:CloudBackupDetailContent.kt:@Composable private fun UnsupportedPasskeyProviderContent + FunctionNaming:CloudBackupEnableOnboardingContent.kt:@Composable internal fun CloudBackupEnableBusyOverlay + FunctionNaming:CloudBackupEnableOnboardingContent.kt:@Composable internal fun CloudBackupEnableOnboardingView + FunctionNaming:CloudBackupEnableOnboardingContent.kt:@Composable private fun OnboardingToggleCard + FunctionNaming:CloudBackupPresentationHost.kt:@Composable fun CloudBackupPresentationHost + FunctionNaming:CloudBackupPresentationHost.kt:@Composable internal fun CloudBackupVerificationPromptDialog + FunctionNaming:CloudBackupPresentationHost.kt:@Composable internal fun CloudBackupVerificationPromptFailurePreviewContent + FunctionNaming:CloudBackupPresentationHost.kt:@Composable internal fun CloudBackupVerificationPromptPreviewContent + FunctionNaming:CloudBackupPresentationHost.kt:@Composable internal fun CloudBackupVerificationPromptRunningPreviewContent + FunctionNaming:CloudBackupPresentationHost.kt:@Composable private fun CloudBackupSuccessFloater + FunctionNaming:CloudBackupPresentationHost.kt:@Composable private fun CloudBackupVerificationPrompt + FunctionNaming:CloudBackupPresentationHost.kt:@Composable private fun CloudBackupVerificationPromptPreviewScaffold + FunctionNaming:CloudBackupPresentationHost.kt:@Composable private fun RowWithCheckIcon + FunctionNaming:CloudBackupPreviewData.kt:@Composable internal fun CloudBackupScreenPreviewContent + FunctionNaming:CloudBackupPreviewData.kt:@Preview( name = "Cloud Backup Detail Dark", widthDp = 393, heightDp = 852, showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES, ) @Composable private fun CloudBackupScreenPreview + FunctionNaming:CloudBackupPreviewData.kt:@Preview( name = "Cloud Backup Detail Light", widthDp = 393, heightDp = 852, showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_NO, ) @Composable private fun CloudBackupScreenLightPreview + FunctionNaming:CloudBackupScreen.kt:@Composable private fun CloudBackupSettingsEnableOnboarding + FunctionNaming:CloudBackupScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CloudBackupScreen + FunctionNaming:CloudBackupScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CloudBackupScreenFrame + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupBitcoinMetadataText + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupGlassCard + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupIconBubble + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupIconText + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupProgressCard + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupProgressContent + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupSectionTitle + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupSimpleActionCard + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun CloudBackupTitledContentSection + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun ErrorInlineMessage + FunctionNaming:CloudBackupVisuals.kt:@Composable internal fun ErrorStateCard + FunctionNaming:CloudBackupVisuals.kt:@Composable private fun LoadingRow + FunctionNaming:CloudBackupWalletRows.kt:@Composable internal fun CloudBackupHeaderSection + FunctionNaming:CloudBackupWalletRows.kt:@Composable internal fun WalletRowsCard + FunctionNaming:CloudBackupWalletRows.kt:@Composable internal fun WalletSections + FunctionNaming:CloudBackupWalletRows.kt:@Composable private fun StatusBadge + FunctionNaming:CloudBackupWalletRows.kt:@Composable private fun WalletItemRow + FunctionNaming:CloudBackupWalletRows.kt:@Composable private fun WalletRowsSection + FunctionNaming:CloudOnlySection.kt:@Composable internal fun CloudOnlySection + FunctionNaming:CloudOnlySection.kt:@Composable internal fun CloudOnlyWalletActionSheetPreviewContent + FunctionNaming:CloudOnlySection.kt:@Composable private fun CloudOnlyWalletActionSheetContent + FunctionNaming:CloudOnlySection.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CloudOnlyWalletActionSheet + FunctionNaming:CoinControlContainer.kt:@Composable fun CoinControlContainer + FunctionNaming:CoinControlCustomAmountSheet.kt:@Composable private fun UtxoDetailRow + FunctionNaming:CoinControlCustomAmountSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlCustomAmountSheet + FunctionNaming:CoinControlSetAmountScreen.kt:@Composable private fun AddressWidget + FunctionNaming:CoinControlSetAmountScreen.kt:@Composable private fun BalanceWidget + FunctionNaming:CoinControlSetAmountScreen.kt:@Composable private fun CoinControlAmountWidget + FunctionNaming:CoinControlSetAmountScreen.kt:@Composable private fun SpendingWidget + FunctionNaming:CoinControlSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlSetAmountScreen + FunctionNaming:ColdWalletQrScanScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ColdWalletQrScanScreen + FunctionNaming:ColumnMajorGrid.kt:@Composable fun <T> ColumnMajorGrid + FunctionNaming:ConfirmationIndicatorView.kt:@Composable fun ConfirmationIndicatorView + FunctionNaming:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview0 + FunctionNaming:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview1 + FunctionNaming:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview2 + FunctionNaming:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview3 + FunctionNaming:CoveNavDisplay.kt:@Composable fun CoveNavDisplay + FunctionNaming:CoveNavDisplay.kt:@Composable private fun LoadAndResetContent + FunctionNaming:CoveNavDisplay.kt:@Composable private fun RouteContent + FunctionNaming:CustomBaseViews.kt:@Composable fun CardItem + FunctionNaming:CustomBaseViews.kt:@Composable fun ClickableInfoRow + FunctionNaming:CustomBaseViews.kt:@Composable fun CustomSpacer + FunctionNaming:CustomBaseViews.kt:@Composable fun HStack + FunctionNaming:CustomBaseViews.kt:@Composable fun InfoRow + FunctionNaming:CustomBaseViews.kt:@Composable fun MaterialDivider + FunctionNaming:CustomBaseViews.kt:@Composable fun MaterialSection + FunctionNaming:CustomBaseViews.kt:@Composable fun SectionHeader + FunctionNaming:CustomBaseViews.kt:@Composable fun SwitchRow + FunctionNaming:CustomBaseViews.kt:@Composable fun ThemedSwitch + FunctionNaming:CustomBaseViews.kt:@Composable fun VStack + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun CardItemPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun CustomSpacerPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun InfoRowPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun MaterialDividerPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun MaterialSectionPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun SectionHeaderPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun SwitchRowPreview + FunctionNaming:CustomBaseViews.kt:@Preview @Composable fun ThemedSwitchPreview + FunctionNaming:CustomFeeRateSheet.kt:@Composable private fun DurationCapsule + FunctionNaming:CustomFeeRateSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomFeeRateSheet + FunctionNaming:DecoyPinView.kt:@Composable fun DecoyPinView + FunctionNaming:DisableCloudBackupSection.kt:@Composable internal fun DisableCloudBackupSection + FunctionNaming:DotMenuView.kt:@Composable fun DotMenuView + FunctionNaming:DotMenuView.kt:@Composable fun DotMenuViewCircle + FunctionNaming:DotMenuView.kt:@Preview @Composable private fun PreviewDotMenuView + FunctionNaming:EnterAddressView.kt:@Composable fun EnterAddressView + FunctionNaming:EnterAmountView.kt:@Composable fun EnterAmountView + FunctionNaming:FeeRateSelectorSheet.kt:@Composable private fun DurationCapsule + FunctionNaming:FeeRateSelectorSheet.kt:@Composable private fun FeeOptionCard + FunctionNaming:FeeRateSelectorSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeeRateSelectorSheet + FunctionNaming:FiatCurrencySettingsScreen.kt:@Composable private fun FiatCurrencyRow + FunctionNaming:FiatCurrencySettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun FiatCurrencySettingsScreen + FunctionNaming:FullPageLoadingView.kt:@Composable fun FullPageLoadingView + FunctionNaming:HeaderIcon.kt:@Composable internal fun CheckWithRingsWidget + FunctionNaming:HeaderIcon.kt:@Composable internal fun TransactionCapsule + FunctionNaming:HiddenPinTextField.kt:@Composable fun HiddenPinTextField + FunctionNaming:HotWalletCreateScreen.kt:@Composable private fun WordCardView + FunctionNaming:HotWalletCreateScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletCreateScreen + FunctionNaming:HotWalletCreateScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletCreateScreenPreview + FunctionNaming:HotWalletImportCard.kt:@Composable internal fun WordInputGrid + FunctionNaming:HotWalletImportCard.kt:@Composable private fun WordInputField + FunctionNaming:HotWalletImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletImportScreen + FunctionNaming:HotWalletImportScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletImportScreenPreview + FunctionNaming:HotWalletNfcScannerSheet.kt:@Composable private fun NfcReadingStateContent + FunctionNaming:HotWalletNfcScannerSheet.kt:@Composable private fun NfcSuccessState + FunctionNaming:HotWalletNfcScannerSheet.kt:@Composable private fun NfcWaitingState + FunctionNaming:HotWalletNfcScannerSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NfcScannerSheet + FunctionNaming:HotWalletQrScannerSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun QrScannerSheet + FunctionNaming:HotWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun HotWalletSelectScreen + FunctionNaming:HotWalletSelectScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletSelectScreenPreview + FunctionNaming:ImageButton.kt:@Composable fun ImageButton + FunctionNaming:ImageButton.kt:@Preview @Composable private fun PreviewImageButton + FunctionNaming:ImportWalletScreen.kt:@Composable private fun EnterWordWidget + FunctionNaming:ImportWalletScreen.kt:@Composable private fun EnterWordsWidget + FunctionNaming:ImportWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImportWalletScreen + FunctionNaming:ImportWalletScreen.kt:@Preview @Composable private fun ImportWalletPreview12 + FunctionNaming:ImportWalletScreen.kt:@Preview @Composable private fun ImportWalletPreview24 + FunctionNaming:LockView.kt:@Composable fun LockView + FunctionNaming:LockView.kt:@Composable private fun BiometricView + FunctionNaming:LockView.kt:@Composable private fun PermissionsNeeded + FunctionNaming:MainActivityAppShell.kt:@Composable internal fun MainActivityAppShell + FunctionNaming:MainActivityGlobalAlerts.kt:@Composable internal fun GlobalAlertHandler + FunctionNaming:MainActivityGlobalAlerts.kt:@Composable private fun GlobalAlertDialog + FunctionNaming:MainActivityGlobalSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SheetContent + FunctionNaming:MainActivityStartupViews.kt:@Composable internal fun BootstrapErrorView + FunctionNaming:MainActivityStartupViews.kt:@Composable internal fun CatastrophicRecoveryView + FunctionNaming:MainActivityStartupViews.kt:@Composable internal fun SplashLoadingView + FunctionNaming:MainSettingsScreen.kt:@Composable private fun BackupExportAuthDialog + FunctionNaming:MainSettingsScreen.kt:@Composable private fun BackupSection + FunctionNaming:MainSettingsScreen.kt:@Composable private fun BetaToggleSection + FunctionNaming:MainSettingsScreen.kt:@Composable private fun FullScreenSettingsModal + FunctionNaming:MainSettingsScreen.kt:@Composable private fun WalletSettingsSection + FunctionNaming:MainSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainSettingsScreen + FunctionNaming:MainSettingsSecuritySection.kt:@Composable internal fun SecuritySection + FunctionNaming:MainSettingsSecuritySection.kt:@Composable private fun SecurityAlertDialog + FunctionNaming:MainSettingsSecuritySection.kt:@Composable private fun SecuritySheetDialog + FunctionNaming:NetworkFeeBottomSheet.kt:@Composable private fun FeeOptionCard + FunctionNaming:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomNetworkFeeBottomSheet + FunctionNaming:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkFeeBottomSheet + FunctionNaming:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun NetworkFeeBottomSheetPreview + FunctionNaming:NetworkFeeBottomSheet.kt:@Preview(showBackground = true) @Composable private fun CustomNetworkFeeContentPreview + FunctionNaming:NetworkFeeBottomSheet.kt:@Preview(showBackground = true) @Composable private fun NetworkFeeContentPreview + FunctionNaming:NetworkSettingsScreen.kt:@Composable private fun NetworkRow + FunctionNaming:NetworkSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkSettingsScreen + FunctionNaming:NewHotWalletContainer.kt:@Composable fun NewHotWalletContainer + FunctionNaming:NewHotWalletContainer.kt:@Composable private fun ImportWalletContainer + FunctionNaming:NewHotWalletContainer.kt:@Composable private fun PendingWalletContainer + FunctionNaming:NewPinView.kt:@Composable fun NewPinView + FunctionNaming:NewWalletContainer.kt:@Composable fun NewWalletContainer + FunctionNaming:NewWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewWalletSelectScreen + FunctionNaming:NewWalletSelectScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun NewWalletSelectScreenPreview + FunctionNaming:NfcHelpSheet.kt:@Composable fun NfcHelpSheet + FunctionNaming:NfcHelpSheet.kt:@Composable private fun InstructionStep + FunctionNaming:NfcHelpSheet.kt:@Preview(showBackground = true) @Composable private fun NfcHelpSheetPreview + FunctionNaming:NfcLabelImportSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcLabelImportSheet + FunctionNaming:NfcScanSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcScanSheet + FunctionNaming:NfcWriteSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcWriteSheet + FunctionNaming:NodeSettingsScreen.kt:@Composable private fun NodeRow + FunctionNaming:NodeSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NodeSettingsScreen + FunctionNaming:NumberPadPinView.kt:@Composable fun NumberPadPinView + FunctionNaming:NumberPadPinView.kt:@Composable private fun NumberButton + FunctionNaming:OnboardingBackupViews.kt:@Composable internal fun OnboardingBackupWalletView + FunctionNaming:OnboardingBackupViews.kt:@Composable internal fun OnboardingCreatingWalletView + FunctionNaming:OnboardingCloudBackupEnableViews.kt:@Composable internal fun OnboardingCloudBackupStepView + FunctionNaming:OnboardingCloudBackupEnableViews.kt:@Composable internal fun OnboardingCloudBackupSuccessView + FunctionNaming:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingCloudBackupDetailsStepView + FunctionNaming:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingHardwareImportCloudBackupStepView + FunctionNaming:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingSoftwareImportCloudBackupStepView + FunctionNaming:OnboardingCloudRestoreProgressViews.kt:@Composable internal fun OnboardingRestoreView + FunctionNaming:OnboardingCloudRestoreProgressViews.kt:@Composable private fun OnboardingRestoreContent + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable internal fun CloudCheckContent + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable internal fun OnboardingRestoreOfferView + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingCloudSearchHero + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingPasskeyCard + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingPasskeyDivider + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingRestoreErrorCard + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingRestoreMessageCard + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Composable private fun ProviderDetailItem + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun OnboardingRestoreOfferWithProviderDatePreview + FunctionNaming:OnboardingCloudRestorePromptViews.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun OnboardingRestoreOfferWithProviderHintPreview + FunctionNaming:OnboardingContainer.kt:@Composable internal fun OnboardingContainer + FunctionNaming:OnboardingContainer.kt:@Composable private fun BackableOnboardingStep + FunctionNaming:OnboardingExchangeFundingView.kt:@Composable internal fun OnboardingExchangeFundingView + FunctionNaming:OnboardingImportViews.kt:@Composable internal fun OnboardingHardwareImportFlowView + FunctionNaming:OnboardingImportViews.kt:@Composable internal fun OnboardingSoftwareImportFlowView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun CloudRestoreFoundAlert + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingHardwareFileImportView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingHardwareNfcImportView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingHardwareQrImportView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingHotWalletImportView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingImportErrorView + FunctionNaming:OnboardingImportViews.kt:@Composable private fun OnboardingImportLoadingView + FunctionNaming:OnboardingPromptViews.kt:@Composable internal fun OnboardingBitcoinChoiceScreen + FunctionNaming:OnboardingPromptViews.kt:@Composable internal fun OnboardingRestoreOfflineScreen + FunctionNaming:OnboardingPromptViews.kt:@Composable internal fun OnboardingRestoreUnavailableScreen + FunctionNaming:OnboardingPromptViews.kt:@Composable internal fun OnboardingStorageChoiceScreen + FunctionNaming:OnboardingPromptViews.kt:@Composable internal fun OnboardingWelcomeScreen + FunctionNaming:OnboardingPromptViews.kt:@Composable private fun OnboardingChoiceDivider + FunctionNaming:OnboardingPromptViews.kt:@Composable private fun OnboardingCloudRestoreChoiceSection + FunctionNaming:OnboardingRecoveryWordsViews.kt:@Composable internal fun OnboardingSecretWordsView + FunctionNaming:OnboardingRecoveryWordsViews.kt:@Composable private fun OnboardingWordCard + FunctionNaming:OnboardingTermsViews.kt:@Composable internal fun OnboardingTermsScreen + FunctionNaming:OnboardingTermsViews.kt:@Composable private fun OnboardingTermsAgreementCard + FunctionNaming:OnboardingTermsViews.kt:@Composable private fun OnboardingTermsCheckIndicator + FunctionNaming:OnboardingTermsViews.kt:@Composable private fun OnboardingTermsCheckboxCard + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingBackground + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingChoiceCard + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingCloudRestoreChoiceCard + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingInlineMessage + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingPrimaryButton + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingPromptScreen + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingSecondaryButton + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingStatusCard + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingStatusHero + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingStepIndicator + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingThinProgressBar + FunctionNaming:OnboardingTheme.kt:@Composable internal fun OnboardingTopBackButton + FunctionNaming:OtherBackupsSection.kt:@Composable internal fun OtherBackupsLoadFailedSection + FunctionNaming:OtherBackupsSection.kt:@Composable internal fun OtherBackupsSection + FunctionNaming:PinCirclesView.kt:@Composable fun PinCirclesView + FunctionNaming:PinCirclesView.kt:@Composable private fun PinCircle + FunctionNaming:QrCodeImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun QrCodeImportScreen + FunctionNaming:QrCodeImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HelpSheet + FunctionNaming:QrCodeScanView.kt:@Composable @androidx.camera.core.ExperimentalGetImage private fun QrScannerContent + FunctionNaming:QrCodeScanView.kt:@OptIn(ExperimentalPermissionsApi::class) @Composable fun QrCodeScanView + FunctionNaming:QrCodeScanView.kt:@OptIn(ExperimentalPermissionsApi::class) @Composable private fun PermissionDeniedContent + FunctionNaming:QrExportView.kt:@Composable fun QrExportView + FunctionNaming:QrExportView.kt:@Composable private fun DensityButtons + FunctionNaming:QrExportView.kt:@Composable private fun MinusButton + FunctionNaming:QrExportView.kt:@Composable private fun PlusButton + FunctionNaming:ReceiveAddressSheet.kt:@Composable private fun ReceiveAddressSheetContent + FunctionNaming:ReceiveAddressSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReceiveAddressSheet + FunctionNaming:ReceiveAddressSheet.kt:@Preview(showBackground = true, heightDp = 800) @Composable private fun ReceiveAddressSheetPreview + FunctionNaming:ReceivedTransactionDetails.kt:@Composable internal fun ReceivedTransactionDetails + FunctionNaming:RecoveryWords.kt:@Composable fun RecoveryWordChip + FunctionNaming:RecoveryWords.kt:@Composable fun RecoveryWords + FunctionNaming:RecoveryWords.kt:@Composable private fun RecoveryWordsGrid + FunctionNaming:RecoveryWords.kt:@OptIn(ExperimentalFoundationApi::class) @Composable private fun RecoveryWordsPager + FunctionNaming:RecoveryWords.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun RecoveryWordsPreview + FunctionNaming:RoundRectImage.kt:@Composable fun RoundRectImage + FunctionNaming:RoundRectImage.kt:@Preview @Composable fun PreviewPainter + FunctionNaming:RouteView.kt:@Composable fun RouteView + FunctionNaming:RouteView.kt:@Composable private fun LoadAndResetContainer + FunctionNaming:SecretWordsScreen.kt:@Composable private fun RecoveryWordsGrid + FunctionNaming:SecretWordsScreen.kt:@Composable private fun SeedQrSheetContent + FunctionNaming:SecretWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SecretWordsScreen + FunctionNaming:SelectedWalletContainer.kt:@Composable fun SelectedWalletContainer + FunctionNaming:SelectedWalletContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SelectedWalletLoadingScreen + FunctionNaming:SelectedWalletScreen.kt:@Composable private fun VerifyReminder + FunctionNaming:SelectedWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SelectedWalletScreen + FunctionNaming:SelectedWalletScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun SelectedWalletFunctionalPreview + FunctionNaming:SendFlowAdvancedDetailsView.kt:@Composable fun SendFlowAdvancedDetailsView + FunctionNaming:SendFlowAdvancedDetailsView.kt:@Composable private fun SectionCard + FunctionNaming:SendFlowAdvancedDetailsView.kt:@Composable private fun SectionDivider + FunctionNaming:SendFlowAdvancedDetailsView.kt:@Composable private fun TxRow + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun AmountWidget + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun BalanceWidget + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun KeyValueRow + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun SummaryWidget + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun SwipeToSendStub + FunctionNaming:SendFlowConfirmScreen.kt:@Composable private fun ThreeDotsAnimation + FunctionNaming:SendFlowConfirmScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowConfirmScreen + FunctionNaming:SendFlowContainer.kt:@Composable fun SendFlowContainer + FunctionNaming:SendFlowContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFlowRouteToScreen + FunctionNaming:SendFlowHardwareDetailsSheet.kt:@Composable internal fun TransactionDetailsSheet + FunctionNaming:SendFlowHardwareScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowHardwareScreen + FunctionNaming:SendFlowHardwareSections.kt:@Composable internal fun AccountSection + FunctionNaming:SendFlowHardwareSections.kt:@Composable internal fun AddressSection + FunctionNaming:SendFlowHardwareSections.kt:@Composable internal fun BalanceHeader + FunctionNaming:SendFlowHardwareSections.kt:@Composable internal fun HardwareSigningSection + FunctionNaming:SendFlowHardwareSections.kt:@Composable private fun SignTapSignerSection + FunctionNaming:SendFlowHardwareSections.kt:@Composable private fun SignTransactionSection + FunctionNaming:SendFlowHardwareSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SendFlowHardwareBottomSheets + FunctionNaming:SendFlowHardwareSigning.kt:@Composable internal fun HardwareConfirmationDialogs + FunctionNaming:SendFlowHardwareSigning.kt:@Composable internal fun HardwareErrorAlert + FunctionNaming:SendFlowHardwareSigning.kt:@Composable internal fun HardwareNfcWriteSheet + FunctionNaming:SendFlowHardwareSigning.kt:@Composable internal fun HardwareQrScanner + FunctionNaming:SendFlowHeaderView.kt:@Composable fun SendFlowHeaderView + FunctionNaming:SendFlowSetAmountScreen.kt:@Composable private fun ImageButton + FunctionNaming:SendFlowSetAmountScreen.kt:@Composable private fun KeyboardToolbar + FunctionNaming:SendFlowSetAmountScreen.kt:@Composable private fun SpendingWidget + FunctionNaming:SendFlowSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowSetAmountScreen + FunctionNaming:SettingsContainer.kt:@Composable fun SettingsContainer + FunctionNaming:SettingsItem.kt:@Composable fun MaterialSettingsItem + FunctionNaming:SettingsItem.kt:@Composable fun SettingsItem + FunctionNaming:SettingsItem.kt:@Preview @Composable fun MaterialSettingsItemPreview + FunctionNaming:SettingsItem.kt:@Preview @Composable fun MaterialSettingsItemSwitchPreview + FunctionNaming:SettingsItem.kt:@Preview @Composable fun SettingsItemGo + FunctionNaming:SettingsItem.kt:@Preview @Composable fun SettingsItemSwitch + FunctionNaming:SettingsListAllWalletsScreen.kt:@Composable private fun SearchBar + FunctionNaming:SettingsListAllWalletsScreen.kt:@Composable private fun WalletRow + FunctionNaming:SettingsListAllWalletsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsListAllWalletsScreen + FunctionNaming:SidebarContainer.kt:@Composable fun SidebarContainer + FunctionNaming:SidebarView.kt:@Composable fun SidebarView + FunctionNaming:SidebarView.kt:@Composable private fun WalletItem + FunctionNaming:TapSignerAdvancedChainCode.kt:@Composable fun TapSignerAdvancedChainCode + FunctionNaming:TapSignerChooseChainCode.kt:@Composable fun TapSignerChooseChainCode + FunctionNaming:TapSignerConfirmPinView.kt:@Composable fun TapSignerConfirmPinView + FunctionNaming:TapSignerContainer.kt:@Composable fun TapSignerContainer + FunctionNaming:TapSignerEnterPinView.kt:@Composable fun TapSignerEnterPinView + FunctionNaming:TapSignerImportRetryView.kt:@Composable fun TapSignerImportRetryView + FunctionNaming:TapSignerImportSuccessView.kt:@Composable fun TapSignerImportSuccessView + FunctionNaming:TapSignerNewPinView.kt:@Composable fun TapSignerNewPinView + FunctionNaming:TapSignerScanningOverlay.kt:@Composable fun TapSignerScanningOverlay + FunctionNaming:TapSignerSetupRetryView.kt:@Composable fun TapSignerSetupRetryView + FunctionNaming:TapSignerSetupSuccessView.kt:@Composable fun TapSignerSetupSuccessView + FunctionNaming:TapSignerStartingPinView.kt:@Composable fun TapSignerStartingPinView + FunctionNaming:Theme.kt:@Composable fun CoveTheme + FunctionNaming:Theme.kt:@Composable fun ForceLightStatusBarIcons + FunctionNaming:Theme.kt:@Composable fun ResetStatusBarToTheme + FunctionNaming:TransactionDetailsContainer.kt:@Composable fun TransactionDetailsContainer + FunctionNaming:TransactionDetailsContainer.kt:@Composable private fun TransactionDetailsLoadError + FunctionNaming:TransactionDetailsScreen.kt:@Composable private fun TransactionDetailsHeaderLabelRow + FunctionNaming:TransactionDetailsScreen.kt:@Composable private fun TransactionLockAction + FunctionNaming:TransactionDetailsScreen.kt:@Composable private fun TransactionLockControls + FunctionNaming:TransactionDetailsScreen.kt:@Composable private fun TransactionLockLoadError + FunctionNaming:TransactionDetailsScreen.kt:@Composable private fun TransactionLockedUtxosBadge + FunctionNaming:TransactionDetailsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen + FunctionNaming:TransactionDetailsWidget.kt:@Composable internal fun DetailsWidget + FunctionNaming:TransactionDetailsWidget.kt:@Composable internal fun FiatPriceSection + FunctionNaming:TransactionDetailsWidget.kt:@Composable internal fun TransactionDetailsWidget + FunctionNaming:TransactionLabelView.kt:@Composable fun TransactionLabelView + FunctionNaming:TransactionsCardView.kt:@Composable fun TransactionsCardView + FunctionNaming:TransactionsRows.kt:@Composable internal fun ConfirmedTransactionWidget + FunctionNaming:TransactionsRows.kt:@Composable internal fun TransactionItem + FunctionNaming:TransactionsRows.kt:@Composable internal fun UnconfirmedTransactionWidget + FunctionNaming:TransactionsRows.kt:@OptIn(ExperimentalFoundationApi::class) @Composable internal fun UnsignedTransactionWidget + FunctionNaming:TransactionsScanState.kt:@Composable internal fun EmptyWalletScanSpinnerState + FunctionNaming:TransactionsScanState.kt:@Composable internal fun EmptyWalletScanState + FunctionNaming:TransactionsScanState.kt:@Composable internal fun TransactionsScanProgressStrip + FunctionNaming:TransactionsScanState.kt:@Composable internal fun TransactionsScanSpinnerStrip + FunctionNaming:UtxoListScreen.kt:@Composable private fun ChangeBadge + FunctionNaming:UtxoListScreen.kt:@Composable private fun SearchBar + FunctionNaming:UtxoListScreen.kt:@Composable private fun SelectionCircle + FunctionNaming:UtxoListScreen.kt:@Composable private fun SortChip + FunctionNaming:UtxoListScreen.kt:@Composable private fun SortRow + FunctionNaming:UtxoListScreen.kt:@OptIn(ExperimentalFoundationApi::class) @Composable private fun UtxoItemRow + FunctionNaming:UtxoListScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun UtxoListScreen + FunctionNaming:UtxoListScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun UtxoListScreenContent + FunctionNaming:VerificationCompleteScreen.kt:@Composable fun VerificationCompleteScreen + FunctionNaming:VerificationCompleteScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun VerificationCompleteScreenPreview + FunctionNaming:VerificationSection.kt:@Composable internal fun VerificationSection + FunctionNaming:VerificationSection.kt:@Composable private fun CancelledVerificationActions + FunctionNaming:VerificationSection.kt:@Composable private fun CancelledVerificationRecoveryContent + FunctionNaming:VerificationSection.kt:@Composable private fun PasskeyConfirmedSectionContent + FunctionNaming:VerificationSection.kt:@Composable private fun VerificationFailureContent + FunctionNaming:VerificationSection.kt:@Composable private fun VerifiedSectionContent + FunctionNaming:VerificationSection.kt:@Composable private fun VerifiedSummary + FunctionNaming:VerificationSection.kt:@Composable private fun VerifyAgainButton + FunctionNaming:VerifyWordsContainer.kt:@Composable fun VerifyWordsContainer + FunctionNaming:VerifyWordsScreen.kt:@Composable private fun OptionChip + FunctionNaming:VerifyWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyWordsScreen + FunctionNaming:WalletBalanceHeaderView.kt:@Composable fun WalletBalanceHeaderView + FunctionNaming:WalletBalanceHeaderView.kt:@Composable internal fun BalanceWidget + FunctionNaming:WalletBalanceHeaderView.kt:@Composable private fun AmountDisplay + FunctionNaming:WalletBalanceHeaderView.kt:@Composable private fun SendReceiveButtons + FunctionNaming:WalletBalanceHeaderView.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun WalletBalanceHeaderViewHiddenPreview + FunctionNaming:WalletBalanceHeaderView.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun WalletBalanceHeaderViewPreview + FunctionNaming:WalletIcon.kt:@Composable fun WalletIcon + FunctionNaming:WalletImportHelpSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletImportHelpSheet + FunctionNaming:WalletMoreOptionsSheet.kt:@Composable private fun MenuOption + FunctionNaming:WalletMoreOptionsSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletMoreOptionsSheet + FunctionNaming:WalletSettingsChangeNameScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletSettingsChangeNameScreen + FunctionNaming:WalletSettingsContainer.kt:@Composable fun WalletSettingsContainer + FunctionNaming:WalletSettingsContainer.kt:@Composable private fun WalletSettingsLoadError + FunctionNaming:WalletSettingsScreen.kt:@Composable private fun WalletColorSelector + FunctionNaming:WalletSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletSettingsScreen + FunctionNaming:WalletSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WalletSheetsHost + FunctionNaming:WipeDataPinView.kt:@Composable fun WipeDataPinView + HasPlatformType:TapSignerManager.kt:TapSignerManager$var id by mutableStateOf(UUID.randomUUID()) private set + ImplicitDefaultLocale:NodeSettingsScreen.kt:errorUnknown.format(e.message ?: "") + ImplicitDefaultLocale:TapSignerAdvancedChainCode.kt:"%02x".format(it) + InjectDispatcher:AboutDebugSection.kt:IO + InjectDispatcher:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$IO + InjectDispatcher:AppManager.kt:AppManager$IO + InjectDispatcher:BackupExportScreen.kt:IO + InjectDispatcher:BackupExportUtils.kt:IO + InjectDispatcher:BackupImportScreen.kt:IO + InjectDispatcher:BackupVerifyScreen.kt:IO + InjectDispatcher:CloudBackupManager.kt:CloudBackupManager$IO + InjectDispatcher:CoinControlManager.kt:CoinControlManager$IO + InjectDispatcher:CustomFeeRateSheet.kt:IO + InjectDispatcher:MoreInfoPopover.kt:IO + InjectDispatcher:NodeSettingsScreen.kt:IO + InjectDispatcher:OnboardingImportViews.kt:IO + InjectDispatcher:PendingWalletManager.kt:PendingWalletManager$IO + InjectDispatcher:SendFlowHardwareImportRoute.kt:IO + InjectDispatcher:SendFlowHardwareSigning.kt:IO + InjectDispatcher:TapCardNfcManager.kt:TapCardNfcManager$IO + InjectDispatcher:TapSignerImportSuccessView.kt:IO + InjectDispatcher:WalletManager.kt:WalletManager$IO + InjectDispatcher:WalletSheets.kt:IO + InstanceOfCheckForException:AboutDebugSection.kt:error is CancellationException + InstanceOfCheckForException:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$error is CancellationException + InstanceOfCheckForException:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$error is DriveHttpException + InstanceOfCheckForException:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$error is CancellationException + LargeClass:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess : CloudStorageAccess + LargeClass:AndroidCloudStorageAccessTest.kt:AndroidCloudStorageAccessTest + LargeClass:AppManager.kt:AppManager : FfiReconcile + LargeClass:WalletManager.kt:WalletManager : WalletManagerReconcilerCloseable + LongMethod:AboutDebugSection.kt:@Composable internal fun AboutDebugSection + LongMethod:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutSettingsScreen + LongMethod:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AboutSettingsContent + LongMethod:AdvancedDetailsBottomSheet.kt:@Composable fun AdvancedDetailsBottomSheet + LongMethod:AndroidCloudStorageAccessTest.kt:AndroidCloudStorageAccessTest$@Test fun sparseDriveAccountIdentityIsResolvedBeforeVerification + LongMethod:AppManager.kt:AppManager$override fun reconcile + LongMethod:AutoSizeText.kt:@Composable fun AutoSizeTextField + LongMethod:BackupExportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupExportScreen + LongMethod:BackupImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupImportScreen + LongMethod:BackupVerifyScreen.kt:@Composable fun VerifyResultContent + LongMethod:BackupVerifyScreen.kt:@Composable fun WalletSummaryCard + LongMethod:BackupVerifyScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupVerifyScreen + LongMethod:BlockExplorerSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockExplorerSettingsScreen + LongMethod:ChooseWalletTypeSheet.kt:@Composable private fun ChooseWalletTypeSheetContent + LongMethod:ChooseWalletTypeSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChooseWalletTypeSheet + LongMethod:CloudBackupDetailContent.kt:@Composable internal fun CloudBackupDetailContent + LongMethod:CloudBackupEnableOnboardingContent.kt:@Composable internal fun CloudBackupEnableOnboardingView + LongMethod:CloudBackupPresentationHost.kt:@Composable fun CloudBackupPresentationHost + LongMethod:CloudBackupPreviewData.kt:private fun cloudBackupPreviewState: CloudBackupState + LongMethod:CloudBackupScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CloudBackupScreen + LongMethod:CloudBackupScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CloudBackupScreenFrame + LongMethod:CloudBackupWalletRows.kt:@Composable internal fun CloudBackupHeaderSection + LongMethod:CloudBackupWalletRows.kt:@Composable private fun WalletItemRow + LongMethod:CloudOnlySection.kt:@Composable internal fun CloudOnlySection + LongMethod:CloudOnlySection.kt:@Composable private fun CloudOnlyWalletActionSheetContent + LongMethod:CoinControlContainer.kt:@Composable fun CoinControlContainer + LongMethod:CoinControlCustomAmountSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlCustomAmountSheet + LongMethod:CoinControlSetAmountScreen.kt:@Composable private fun AddressWidget + LongMethod:CoinControlSetAmountScreen.kt:@Composable private fun CoinControlAmountWidget + LongMethod:CoinControlSetAmountScreen.kt:@Composable private fun SpendingWidget + LongMethod:CoinControlSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlSetAmountScreen + LongMethod:ColdWalletQrScanScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ColdWalletQrScanScreen + LongMethod:CoveApplication.kt:CoveApplication$private fun cleanupFfiObjects + LongMethod:CustomFeeRateSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomFeeRateSheet + LongMethod:DisableCloudBackupSection.kt:@Composable internal fun DisableCloudBackupSection + LongMethod:EnterAddressView.kt:@Composable fun EnterAddressView + LongMethod:EnterAmountView.kt:@Composable fun EnterAmountView + LongMethod:FeeRateSelectorSheet.kt:@Composable private fun FeeOptionCard + LongMethod:FeeRateSelectorSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeeRateSelectorSheet + LongMethod:HeaderIcon.kt:@Composable internal fun CheckWithRingsWidget + LongMethod:HotWalletCreateScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletCreateScreen + LongMethod:HotWalletImportCard.kt:@Composable internal fun WordInputGrid + LongMethod:HotWalletImportCard.kt:@Composable private fun WordInputField + LongMethod:HotWalletImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletImportScreen + LongMethod:HotWalletNfcScannerSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NfcScannerSheet + LongMethod:HotWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun HotWalletSelectScreen + LongMethod:ImportWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImportWalletScreen + LongMethod:LockView.kt:@Composable fun LockView + LongMethod:LockView.kt:@Composable private fun BiometricView + LongMethod:MainActivity.kt:MainActivity$override fun onCreate + LongMethod:MainActivityAppShell.kt:@Composable internal fun MainActivityAppShell + LongMethod:MainActivityGlobalAlerts.kt:@Composable private fun GlobalAlertDialog + LongMethod:MainActivityStartupViews.kt:@Composable internal fun CatastrophicRecoveryView + LongMethod:MainSettingsScreen.kt:@Composable private fun BackupExportAuthDialog + LongMethod:MainSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainSettingsScreen + LongMethod:MainSettingsSecuritySection.kt:@Composable internal fun SecuritySection + LongMethod:MainSettingsSecuritySection.kt:@Composable private fun SecurityAlertDialog + LongMethod:MainSettingsSecuritySection.kt:@Composable private fun SecuritySheetDialog + LongMethod:MoreInfoPopover.kt:@Composable fun rememberWalletExportLaunchers: WalletExportLaunchers + LongMethod:NetworkFeeBottomSheet.kt:@Composable private fun FeeOptionCard + LongMethod:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomNetworkFeeBottomSheet + LongMethod:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun NetworkFeeBottomSheetPreview + LongMethod:NetworkSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkSettingsScreen + LongMethod:NewWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewWalletSelectScreen + LongMethod:NfcLabelImportSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcLabelImportSheet + LongMethod:NfcReader.kt:NfcReader$private fun processNdefMessage + LongMethod:NfcScanSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcScanSheet + LongMethod:NfcWriteSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NfcWriteSheet + LongMethod:NodeSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NodeSettingsScreen + LongMethod:NumberPadPinView.kt:@Composable fun NumberPadPinView + LongMethod:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingCloudBackupDetailsStepView + LongMethod:OnboardingCloudBackupEnableViews.kt:@Composable private fun OnboardingHardwareImportCloudBackupStepView + LongMethod:OnboardingCloudRestoreProgressViews.kt:@Composable private fun OnboardingRestoreContent + LongMethod:OnboardingCloudRestorePromptViews.kt:@Composable internal fun OnboardingRestoreOfferView + LongMethod:OnboardingCloudRestorePromptViews.kt:@Composable private fun OnboardingPasskeyCard + LongMethod:OnboardingContainer.kt:@Composable internal fun OnboardingContainer + LongMethod:OnboardingExchangeFundingView.kt:@Composable internal fun OnboardingExchangeFundingView + LongMethod:OnboardingHelpersTest.kt:OnboardingHelpersTest$@Test fun shouldCompleteOnboardingCloudBackupRequiresCompletedVerification + LongMethod:OnboardingImportViews.kt:@Composable internal fun OnboardingSoftwareImportFlowView + LongMethod:OnboardingImportViews.kt:@Composable private fun OnboardingHardwareFileImportView + LongMethod:OnboardingImportViews.kt:@Composable private fun OnboardingHardwareNfcImportView + LongMethod:OnboardingRecoveryWordsViews.kt:@Composable internal fun OnboardingSecretWordsView + LongMethod:OnboardingTermsViews.kt:@Composable internal fun OnboardingTermsScreen + LongMethod:OnboardingTermsViews.kt:@Composable private fun OnboardingTermsAgreementCard + LongMethod:OnboardingTheme.kt:@Composable internal fun OnboardingPromptScreen + LongMethod:OtherBackupsSection.kt:@Composable internal fun OtherBackupsSection + LongMethod:QrCodeImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun QrCodeImportScreen + LongMethod:QrCodeImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HelpSheet + LongMethod:QrCodeScanView.kt:@Composable @androidx.camera.core.ExperimentalGetImage private fun QrScannerContent + LongMethod:QrExportView.kt:@Composable fun QrExportView + LongMethod:ReceiveAddressSheet.kt:@Composable private fun ReceiveAddressSheetContent + LongMethod:ReceiveAddressSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReceiveAddressSheet + LongMethod:ReceivedTransactionDetails.kt:@Composable internal fun ReceivedTransactionDetails + LongMethod:SecretWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SecretWordsScreen + LongMethod:SelectedWalletContainer.kt:@Composable fun SelectedWalletContainer + LongMethod:SelectedWalletContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SelectedWalletLoadingScreen + LongMethod:SelectedWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SelectedWalletScreen + LongMethod:SendFlowAdvancedDetailsView.kt:@Composable fun SendFlowAdvancedDetailsView + LongMethod:SendFlowAdvancedDetailsView.kt:@Composable private fun TxRow + LongMethod:SendFlowConfirmScreen.kt:@Composable private fun AmountWidget + LongMethod:SendFlowConfirmScreen.kt:@Composable private fun SwipeToSendStub + LongMethod:SendFlowConfirmScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowConfirmScreen + LongMethod:SendFlowContainer.kt:@Composable fun SendFlowContainer + LongMethod:SendFlowContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFlowRouteToScreen + LongMethod:SendFlowHardwareDetailsSheet.kt:@Composable internal fun TransactionDetailsSheet + LongMethod:SendFlowHardwareScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowHardwareScreen + LongMethod:SendFlowHardwareSections.kt:@Composable internal fun BalanceHeader + LongMethod:SendFlowHardwareSections.kt:@Composable private fun SignTransactionSection + LongMethod:SendFlowHardwareSigning.kt:@Composable internal fun HardwareConfirmationDialogs + LongMethod:SendFlowSetAmountScreen.kt:@Composable private fun SpendingWidget + LongMethod:SendFlowSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowSetAmountScreen + LongMethod:SettingsContainer.kt:@Composable fun SettingsContainer + LongMethod:SettingsListAllWalletsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsListAllWalletsScreen + LongMethod:SidebarContainer.kt:@Composable fun SidebarContainer + LongMethod:SidebarView.kt:@Composable fun SidebarView + LongMethod:TapCardNfcManager.kt:TapCardNfcManager$suspend fun <T> performTapSignerCmd: Pair<T, TapSignerResponse?> + LongMethod:TapSignerAdvancedChainCode.kt:@Composable fun TapSignerAdvancedChainCode + LongMethod:TapSignerChooseChainCode.kt:@Composable fun TapSignerChooseChainCode + LongMethod:TapSignerConfirmPinView.kt:@Composable fun TapSignerConfirmPinView + LongMethod:TapSignerContainer.kt:@Composable fun TapSignerContainer + LongMethod:TapSignerEnterPinView.kt:@Composable fun TapSignerEnterPinView + LongMethod:TapSignerImportRetryView.kt:@Composable fun TapSignerImportRetryView + LongMethod:TapSignerImportSuccessView.kt:@Composable fun TapSignerImportSuccessView + LongMethod:TapSignerNewPinView.kt:@Composable fun TapSignerNewPinView + LongMethod:TapSignerScanningOverlay.kt:@Composable fun TapSignerScanningOverlay + LongMethod:TapSignerSetupRetryView.kt:@Composable fun TapSignerSetupRetryView + LongMethod:TapSignerSetupSuccessView.kt:@Composable fun TapSignerSetupSuccessView + LongMethod:TapSignerStartingPinView.kt:@Composable fun TapSignerStartingPinView + LongMethod:TransactionDetailsContainer.kt:@Composable fun TransactionDetailsContainer + LongMethod:TransactionDetailsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen + LongMethod:TransactionDetailsWidget.kt:@Composable internal fun TransactionDetailsWidget + LongMethod:TransactionLabelView.kt:@Composable fun TransactionLabelView + LongMethod:TransactionsCardView.kt:@Composable fun TransactionsCardView + LongMethod:TransactionsCardView.kt:@OptIn(ExperimentalFoundationApi::class) fun LazyListScope.transactionItems + LongMethod:TransactionsRows.kt:@Composable internal fun ConfirmedTransactionWidget + LongMethod:TransactionsRows.kt:@Composable internal fun TransactionItem + LongMethod:TransactionsRows.kt:@Composable internal fun UnconfirmedTransactionWidget + LongMethod:TransactionsRows.kt:@OptIn(ExperimentalFoundationApi::class) @Composable internal fun UnsignedTransactionWidget + LongMethod:UtxoListScreen.kt:@OptIn(ExperimentalFoundationApi::class) @Composable private fun UtxoItemRow + LongMethod:UtxoListScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun UtxoListScreen + LongMethod:UtxoListScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun UtxoListScreenContent + LongMethod:VerificationCompleteScreen.kt:@Composable fun VerificationCompleteScreen + LongMethod:VerificationSection.kt:@Composable internal fun VerificationSection + LongMethod:VerifyWordsContainer.kt:@Composable fun VerifyWordsContainer + LongMethod:VerifyWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyWordsScreen + LongMethod:WalletBalanceHeaderView.kt:@Composable internal fun BalanceWidget + LongMethod:WalletImportHelpSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletImportHelpSheet + LongMethod:WalletManager.kt:WalletManager$private fun apply + LongMethod:WalletMoreOptionsSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletMoreOptionsSheet + LongMethod:WalletSettingsChangeNameScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletSettingsChangeNameScreen + LongMethod:WalletSettingsContainer.kt:@Composable fun WalletSettingsContainer + LongMethod:WalletSettingsScreen.kt:@Composable private fun WalletColorSelector + LongMethod:WalletSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletSettingsScreen + LongMethod:WalletSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WalletSheetsHost + LongParameterList:AboutSettingsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AboutSettingsContent + LongParameterList:AsyncView.kt:@Composable fun AsyncText + LongParameterList:AutoSizeText.kt:@Composable fun AutoSizeText + LongParameterList:AutoSizeText.kt:@Composable fun AutoSizeTextField + LongParameterList:AutoSizeText.kt:@Composable fun BalanceAutoSizeText + LongParameterList:AutoSizeText.kt:private fun calculateOptimalFontSize: TextUnit + LongParameterList:AutoSizeText.kt:private fun calculateOptimalFontSizeAndWidth: Pair<TextUnit, Float> + LongParameterList:ChoiceDialog.kt:@Composable internal fun ChoiceAlertDialog + LongParameterList:CloudBackupEnableOnboardingContent.kt:@Composable internal fun CloudBackupEnableOnboardingView + LongParameterList:CloudBackupPresentationHost.kt:@Composable internal fun CloudBackupVerificationPromptDialog + LongParameterList:CloudBackupVisuals.kt:@Composable internal fun CloudBackupIconBubble + LongParameterList:CloudBackupVisuals.kt:@Composable internal fun CloudBackupIconText + LongParameterList:CloudBackupVisuals.kt:@Composable internal fun CloudBackupTitledContentSection + LongParameterList:CloudBackupWalletRows.kt:@Composable private fun WalletRowsSection + LongParameterList:CoinControlSetAmountScreen.kt:@Composable private fun AddressWidget + LongParameterList:CoinControlSetAmountScreen.kt:@Composable private fun SpendingWidget + LongParameterList:CoinControlSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoinControlSetAmountScreen + LongParameterList:ColumnMajorGrid.kt:@Composable fun <T> ColumnMajorGrid + LongParameterList:CustomFeeRateSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomFeeRateSheet + LongParameterList:DotMenuView.kt:@Composable fun DotMenuView + LongParameterList:DotMenuView.kt:@Composable fun DotMenuViewCircle + LongParameterList:EnterAddressView.kt:@Composable fun EnterAddressView + LongParameterList:EnterAmountView.kt:@Composable fun EnterAmountView + LongParameterList:FeeRateSelectorSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeeRateSelectorSheet + LongParameterList:HiddenPinTextField.kt:@Composable fun HiddenPinTextField + LongParameterList:HotWalletImportCard.kt:@Composable internal fun WordInputGrid + LongParameterList:HotWalletImportCard.kt:@Composable private fun WordInputField + LongParameterList:HotWalletImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HotWalletImportScreen + LongParameterList:ImageButton.kt:@Composable fun ImageButton + LongParameterList:MainActivityAppShell.kt:@Composable internal fun MainActivityAppShell + LongParameterList:MainActivityStartupViews.kt:@Composable internal fun CatastrophicRecoveryView + LongParameterList:MainSettingsSecuritySection.kt:@Composable private fun SecurityAlertDialog + LongParameterList:MainSettingsSecuritySection.kt:@Composable private fun SecuritySheetDialog + LongParameterList:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomNetworkFeeBottomSheet + LongParameterList:NewWalletSelectScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewWalletSelectScreen + LongParameterList:NumberPadPinView.kt:@Composable fun NumberPadPinView + LongParameterList:OnboardingBackupViews.kt:@Composable internal fun OnboardingBackupWalletView + LongParameterList:OnboardingCloudRestorePromptViews.kt:@Composable internal fun OnboardingRestoreOfferView + LongParameterList:OnboardingImportViews.kt:@Composable internal fun OnboardingSoftwareImportFlowView + LongParameterList:OnboardingPromptViews.kt:@Composable private fun OnboardingCloudRestoreChoiceSection + LongParameterList:OnboardingTheme.kt:@Composable internal fun OnboardingPromptScreen + LongParameterList:OnboardingTheme.kt:@Composable internal fun OnboardingStatusCard + LongParameterList:QrCodeScanView.kt:private fun handleQrCode + LongParameterList:QrExportView.kt:@Composable fun QrExportView + LongParameterList:ReceiveAddressSheet.kt:@Composable private fun ReceiveAddressSheetContent + LongParameterList:RoundRectImage.kt:@Composable fun RoundRectImage + LongParameterList:SelectedWalletScreen.kt:@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SelectedWalletScreen + LongParameterList:SendFlowConfirmScreen.kt:@Composable private fun KeyValueRow + LongParameterList:SendFlowConfirmScreen.kt:@Composable private fun SwipeToSendStub + LongParameterList:SendFlowConfirmScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowConfirmScreen + LongParameterList:SendFlowContainer.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFlowRouteToScreen + LongParameterList:SendFlowHardwareSigning.kt:@Composable internal fun HardwareConfirmationDialogs + LongParameterList:SendFlowSetAmountScreen.kt:@Composable private fun SpendingWidget + LongParameterList:SendFlowSetAmountScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun SendFlowSetAmountScreen + LongParameterList:SettingsItem.kt:@Composable fun MaterialSettingsItem + LongParameterList:SettingsItem.kt:@Composable fun SettingsItem + LongParameterList:TapSignerConfirmPinView.kt:private suspend fun checkPin + LongParameterList:TapSignerEnterPinView.kt:private suspend fun backupAction + LongParameterList:TapSignerEnterPinView.kt:private suspend fun deriveAction + LongParameterList:TapSignerEnterPinView.kt:private suspend fun runAction + LongParameterList:TapSignerEnterPinView.kt:private suspend fun signAction + LongParameterList:TransactionDetailsScreen.kt:@Composable private fun TransactionDetailsHeaderLabelRow + LongParameterList:TransactionDetailsScreen.kt:@Composable private fun TransactionLockAction + LongParameterList:TransactionDetailsScreen.kt:@Composable private fun TransactionLockControls + LongParameterList:TransactionDetailsWidget.kt:@Composable internal fun DetailsWidget + LongParameterList:TransactionDetailsWidget.kt:@Composable internal fun FiatPriceSection + LongParameterList:TransactionDetailsWidget.kt:@Composable internal fun TransactionDetailsWidget + LongParameterList:TransactionsCardView.kt:@Composable fun TransactionsCardView + LongParameterList:TransactionsCardView.kt:@OptIn(ExperimentalFoundationApi::class) fun LazyListScope.transactionItems + LongParameterList:TransactionsRows.kt:@Composable internal fun ConfirmedTransactionWidget + LongParameterList:TransactionsRows.kt:@Composable internal fun TransactionItem + LongParameterList:TransactionsRows.kt:@Composable internal fun UnconfirmedTransactionWidget + LongParameterList:TransactionsRows.kt:@OptIn(ExperimentalFoundationApi::class) @Composable internal fun UnsignedTransactionWidget + LongParameterList:TransactionsRows.kt:private fun formatAmountFor: String + LongParameterList:UtxoListScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun UtxoListScreenContent + LongParameterList:VerifyWordsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyWordsScreen + LongParameterList:WalletBalanceHeaderView.kt:@Composable fun WalletBalanceHeaderView + LongParameterList:WalletBalanceHeaderView.kt:@Composable internal fun BalanceWidget + LongParameterList:WalletBalanceHeaderView.kt:@Composable private fun SendReceiveButtons + LongParameterList:WalletMoreOptionsSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletMoreOptionsSheet + LongParameterList:WalletSheets.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WalletSheetsHost + MagicNumber:AboutSettingsScreen.kt:2000 + MagicNumber:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$200 + MagicNumber:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$299 + MagicNumber:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$404 + MagicNumber:AndroidCloudStorageSupport.kt:1_000_000L + MagicNumber:AndroidCloudStorageSupport.kt:32 + MagicNumber:AndroidPasskeyProvider.kt:120_000 + MagicNumber:AndroidPasskeyProvider.kt:257 + MagicNumber:AndroidPasskeyProvider.kt:7 + MagicNumber:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$1_000 + MagicNumber:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$32 + MagicNumber:AutoSizeText.kt:0.1f + MagicNumber:BackupVerifyScreen.kt:50 + MagicNumber:CoinControlCustomAmountSheet.kt:200 + MagicNumber:CoveNavDisplay.kt:100 + MagicNumber:CoveNavDisplay.kt:200 + MagicNumber:CoveNavDisplay.kt:300 + MagicNumber:FfiColorExt.kt:0xFFAF52DE + MagicNumber:FfiColorExt.kt:0xFFFF2D55 + MagicNumber:FfiColorExt.kt:0xFFFF9500 + MagicNumber:HapticFeedbackExt.kt:100 + MagicNumber:HapticFeedbackExt.kt:50 + MagicNumber:HeaderIcon.kt:0.15f + MagicNumber:HeaderIcon.kt:0.1f + MagicNumber:HeaderIcon.kt:0.4f + MagicNumber:HeaderIcon.kt:0.55f + MagicNumber:HeaderIcon.kt:0.5f + MagicNumber:HeaderIcon.kt:0.85f + MagicNumber:HeaderIcon.kt:0.9f + MagicNumber:HiddenPinTextField.kt:200 + MagicNumber:HotWalletImportCard.kt:100 + MagicNumber:HotWalletImportScreen.kt:12 + MagicNumber:HotWalletImportScreen.kt:200 + MagicNumber:HotWalletImportScreen.kt:24 + MagicNumber:HotWalletImportScreen.kt:50 + MagicNumber:HotWalletNfcScannerSheet.kt:0xFF4CAF50 + MagicNumber:HotWalletNfcScannerSheet.kt:3 + MagicNumber:HotWalletNfcScannerSheet.kt:300 + MagicNumber:ImportWalletScreen.kt:50 + MagicNumber:LockView.kt:30_000 + MagicNumber:MainActivityStartupBootstrap.kt:66 + MagicNumber:MainActivityStartupViews.kt:0.6f + MagicNumber:MainSettingsScreen.kt:0xFFFF9800 + MagicNumber:MainSettingsScreen.kt:50 + MagicNumber:NewWalletSelectScreen.kt:800 + MagicNumber:NfcLabelImportSheet.kt:0xFF4CAF50 + MagicNumber:NfcLabelImportSheet.kt:3 + MagicNumber:NfcLabelImportSheet.kt:300 + MagicNumber:NfcScanSheet.kt:0xFF4CAF50 + MagicNumber:NfcScanSheet.kt:3 + MagicNumber:NfcScanSheet.kt:300 + MagicNumber:NfcWriteSheet.kt:0xFF4CAF50 + MagicNumber:NfcWriteSheet.kt:3 + MagicNumber:NfcWriteSheet.kt:300 + MagicNumber:NumberPadPinView.kt:100 + MagicNumber:NumberPadPinView.kt:10f + MagicNumber:NumberPadPinView.kt:20f + MagicNumber:NumberPadPinView.kt:3 + MagicNumber:NumberPadPinView.kt:30f + MagicNumber:NumberPadPinView.kt:490 + MagicNumber:NumberPadPinView.kt:70 + MagicNumber:NumberPadPinView.kt:9 + MagicNumber:OnboardingBackupViews.kt:900 + MagicNumber:OnboardingRecoveryWordsViews.kt:74 + MagicNumber:OnboardingTheme.kt:0x801E3A5C + MagicNumber:OnboardingTheme.kt:0xFF1E4A6B + MagicNumber:OnboardingTheme.kt:0xFF2A5A8B + MagicNumber:OnboardingTheme.kt:100f + MagicNumber:OnboardingTheme.kt:140f + MagicNumber:OnboardingTheme.kt:280f + MagicNumber:OnboardingTheme.kt:920f + MagicNumber:QrCodeScanView.kt:1.2f + MagicNumber:QrCodeScanView.kt:5f + MagicNumber:QrExportView.kt:50 + MagicNumber:ReceivedTransactionDetails.kt:5000 + MagicNumber:SelectedWalletScreen.kt:0xFFFF9800 + MagicNumber:SelectedWalletScreen.kt:0xFFFFEB3B + MagicNumber:SelectedWalletScreen.kt:1000f + MagicNumber:SendFlowConfirmScreen.kt:3 + MagicNumber:SendFlowContainer.kt:5 + MagicNumber:SendFlowContainer.kt:50 + MagicNumber:SendFlowContainer.kt:8 + MagicNumber:SendFlowHardwareDetailsSheet.kt:3f + MagicNumber:SendFlowHardwareSections.kt:3f + MagicNumber:SendFlowPresenter.kt:SendFlowPresenter$500 + MagicNumber:SendFlowSetAmountScreen.kt:350 + MagicNumber:TapSignerAdvancedChainCode.kt:64 + MagicNumber:TapSignerConfirmPinView.kt:10f + MagicNumber:TapSignerConfirmPinView.kt:200 + MagicNumber:TapSignerConfirmPinView.kt:20f + MagicNumber:TapSignerConfirmPinView.kt:30f + MagicNumber:TapSignerConfirmPinView.kt:6 + MagicNumber:TapSignerConfirmPinView.kt:70 + MagicNumber:TapSignerContainer.kt:2000 + MagicNumber:TapSignerNewPinView.kt:6 + MagicNumber:TapSignerScanningOverlay.kt:3 + MagicNumber:TapSignerScanningOverlay.kt:400 + MagicNumber:TapSignerStartingPinView.kt:0xFF3A4254 + MagicNumber:TapSignerStartingPinView.kt:200 + MagicNumber:TapSignerStartingPinView.kt:6 + MagicNumber:TransactionDetailsScreen.kt:0.72f + MagicNumber:TransactionDetailsScreen.kt:3 + MagicNumber:TransactionsRows.kt:0.6f + MagicNumber:TransactionsScanState.kt:0.72f + MagicNumber:VerificationCompleteScreen.kt:0.46f + MagicNumber:VerificationCompleteScreen.kt:3f + MagicNumber:VerifyWordsScreen.kt:10f + MagicNumber:VerifyWordsScreen.kt:4 + MagicNumber:WalletBalanceHeaderView.kt:0.5f + MagicNumber:WalletColorExt.kt:0xFF007AFF + MagicNumber:WalletColorExt.kt:0xFF34C759 + MagicNumber:WalletColorExt.kt:0xFF5C6BC0 + MagicNumber:WalletColorExt.kt:0xFF8E8E93 + MagicNumber:WalletColorExt.kt:0xFF9E9E9E + MagicNumber:WalletColorExt.kt:0xFFAF52DE + MagicNumber:WalletColorExt.kt:0xFFB2DFDB + MagicNumber:WalletColorExt.kt:0xFFBBDEFB + MagicNumber:WalletColorExt.kt:0xFFD7CCC8 + MagicNumber:WalletColorExt.kt:0xFFE0F2F1 + MagicNumber:WalletColorExt.kt:0xFFF5F5F5 + MagicNumber:WalletColorExt.kt:0xFFFF2D55 + MagicNumber:WalletColorExt.kt:0xFFFF3B30 + MagicNumber:WalletColorExt.kt:0xFFFF9500 + MagicNumber:WalletColorExt.kt:0xFFFFCC00 + MagicNumber:WalletColorExt.kt:0xFFFFCDD2 + MagicNumber:WalletColorExt.kt:0xFFFFF9C4 + MagicNumber:WalletColorExt.kt:0xFFFFFDE7 + MagicNumber:WalletSettingsScreen.kt:3f + MagicNumber:WalletSettingsScreen.kt:5 + MatchingDeclarationName:ChoiceDialog.kt:DialogChoice + MatchingDeclarationName:CloudBackupDetailContent.kt:CloudBackupDetailBodyState + MatchingDeclarationName:CloudBackupEnableOnboardingContent.kt:CloudBackupEnableOnboardingContext + MatchingDeclarationName:Extension.kt:Bip39AutoComplete : Bip39AutoCompleteAutoComplete + MatchingDeclarationName:HotWalletSelectScreen.kt:NextScreenDialog + MatchingDeclarationName:Motion.kt:MaterialMotion + MatchingDeclarationName:SendFlowContainer.kt:SendState + MatchingDeclarationName:SendFlowHardwareImportRoute.kt:TransactionImportErrors + MatchingDeclarationName:SendFlowHardwareSheets.kt:HardwareSheetState + MatchingDeclarationName:Spacing.kt:MaterialSpacing + MaxLineLength:AboutDebugSection.kt:text = { Text("Clears local keychain and DB backup state but keeps Google Drive files intact. Use this to test the recovery flow.") } + MaxLineLength:AboutSettingsScreen.kt:Text("Clears local keychain and DB backup state but keeps Google Drive files intact. Use this to test the recovery flow.") + MaxLineLength:AboutSettingsScreen.kt:text = { Text(if (currentlyEnabled) "This will hide experimental features" else "This will enable experimental features") } + MaxLineLength:AndroidCloudStorageAccessTest.kt:AndroidCloudStorageAccessTest.RecordingDriveAuthorization$var account: DriveAccountIdentity? = DriveAccountIdentity(googleAccountId = "account-1", email = "person@example.com") + MaxLineLength:BackupExportScreen.kt:android.util.Log.w("BackupExport", "Failed to save to password manager: ${e.type}: ${e.message}") + MaxLineLength:BackupExportScreen.kt:infoMessage = "Unable to save to password manager. Make sure a password manager is set up on your device. Password has been copied to your clipboard." + MaxLineLength:BackupExportScreen.kt:text = { Text("Save the backup password to your password manager so you can retrieve it later during import.") } + MaxLineLength:BackupExportScreen.kt:text = { Text("This backup will contain all your wallet private keys. Make sure you keep the file and password secure.") } + MaxLineLength:BackupExportScreen.kt:visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + MaxLineLength:BackupImportScreen.kt:text = { Text("This will import wallets and restore settings from the backup. Existing wallets with the same fingerprint will be skipped.") } + MaxLineLength:BackupImportScreen.kt:tint = if (fileData != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + MaxLineLength:BackupImportScreen.kt:visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + MaxLineLength:BackupVerifyScreen.kt:color = if (wallet.alreadyOnDevice) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary + MaxLineLength:BackupVerifyScreen.kt:tint = if (fileData != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + MaxLineLength:BackupVerifyScreen.kt:visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + MaxLineLength:CloudBackupDetailContent.kt:PendingUploadConfirmationStatus(isBlockedOnAuthorization = manager.syncState is CloudBackupSyncState.Blocked) + MaxLineLength:CloudBackupDetailContent.kt:body = "This passkey provider can't create the secure passkey required for Cloud Backup. Try again with a supported password manager such as Google Password Manager, 1Password, or Bitwarden." + MaxLineLength:CloudBackupDetailContent.kt:body = "Your cloud backup is not accessible until you use an existing passkey or add a new one. Without it, your backups can't be restored." + MaxLineLength:CloudBackupEnableOnboardingContent.kt:. + MaxLineLength:CloudBackupEnableOnboardingContent.kt:Row + MaxLineLength:CloudBackupEnableOnboardingContent.kt:text = "Cloud Backup is end-to-end encrypted before it leaves your device and stored in Google Drive, secured by a passkey that only you control." + MaxLineLength:CloudBackupEnableOnboardingContent.kt:text = "I understand that I need access to my Google account. If I lose access to my passkey or my Google account, my Cloud Backup won't be recoverable." + MaxLineLength:CloudBackupEnableOnboardingContent.kt:text = "I understand that my passkey is required to access my Cloud Backup. I must not delete my passkey." + MaxLineLength:CloudBackupPresentationHost.kt:"Add a new passkey to restore access to your cloud backup. Until you do, your backups can't be restored." + MaxLineLength:CloudBackupPresentationHost.kt:"Creating a new Cloud Backup will not include wallets from your previous backup. If you still have access to the passkey named Cove Cloud Backup (${it.nameSuffix}), use that passkey instead." + MaxLineLength:CloudBackupPresentationHost.kt:message = "Confirming your updated cloud backup can be decrypted and restored. Continuing may ask for your passkey." + MaxLineLength:CloudBackupPresentationHost.kt:message = "Verify your updated cloud backup now to confirm it is accessible. Continuing may ask for your passkey." + MaxLineLength:CloudBackupPresentationHost.kt:var isActivityResumed by remember { mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) } + MaxLineLength:CloudBackupScreen.kt:"This will rebuild the backup index from wallets on this device. Wallets that only exist in the cloud backup will no longer be referenced." + MaxLineLength:CloudBackupScreen.kt:"This will replace your entire cloud backup. Wallets that only exist in the current cloud backup will be lost." + MaxLineLength:CloudBackupVisuals.kt:Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + MaxLineLength:CloudBackupWalletRows.kt:-> + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.CONFIRMED -> StatusBadgeStyle("Confirmed", colors.success, colors.successFill, colors.successBorder, Icons.Default.Check) + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.DELETED_FROM_DEVICE -> StatusBadgeStyle("Not on device", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.DoNotDisturbOn) + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.DIRTY -> StatusBadgeStyle("Dirty", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.WarningAmber) + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.FAILED -> StatusBadgeStyle("Failed", colors.danger, colors.dangerFill, colors.dangerBorder, Icons.Default.WarningAmber) + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.REMOTE_STATE_UNKNOWN -> StatusBadgeStyle("Unknown", colors.secondaryText, colors.cardFill, colors.cardBorder, Icons.Default.WarningAmber) + MaxLineLength:CloudBackupWalletRows.kt:CloudBackupWalletStatus.UNSUPPORTED_VERSION -> StatusBadgeStyle("Unsupported", colors.warning, colors.warningFill, colors.warningBorder, Icons.Default.WarningAmber) + MaxLineLength:CloudBackupWalletRows.kt:is CloudSyncHealth.AuthorizationRequired -> Triple(Icons.Default.WarningAmber, colors.danger, "Google Drive access needs to be reconnected: ${syncHealth.v1}") + MaxLineLength:CloudBackupWalletRows.kt:is CloudSyncHealth.Failed -> Triple(Icons.Default.WarningAmber, colors.danger, "Sync error: ${syncHealth.v1}") + MaxLineLength:CloudBackupWalletRows.kt:is CloudSyncHealth.NoFiles -> Triple(Icons.Default.CloudOff, colors.secondaryText, "No cloud backup files uploaded yet") + MaxLineLength:CloudBackupWalletRows.kt:is CloudSyncHealth.Unavailable -> Triple(Icons.Default.CloudOff, colors.secondaryText, "Google Drive is unavailable") + MaxLineLength:CoinControlContainer.kt:message = "The wallet could not be opened for coin control. Please try again from the wallet screen." + MaxLineLength:CoinControlSetAmountScreen.kt:AsyncText(text = feeAmount, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + MaxLineLength:CoveApplication.kt:CoveApplication$Log.d(TAG, "[LIFECYCLE] < ${AUTO_UNLOCK_THRESHOLD_PIN_ONLY}s since lock (PIN only, no decoy), auto-unlocking") + MaxLineLength:CustomFeeRateSheet.kt:android.util.Log.e("CustomFeeRateSheet", "Unexpected error calculating fee: ${e.javaClass.simpleName} - ${e.message}", e) + MaxLineLength:CustomFeeRateSheet.kt:message = "The fee rate you entered is too high, we automatically selected a lower fee" + MaxLineLength:CustomFeeRateSheet.kt:val computed = presenter.erroredFeeRate?.let { minOf(it + CustomFeeRateConstants.FEE_RATE_EPSILON, fast3) } ?: fast3 + MaxLineLength:DisableCloudBackupSection.kt:"Disabling Cloud Backup will permanently delete your current Cove cloud backups from cloud storage. Wallets already on this device will stay on this device, but they will no longer be backed up to cloud storage." + MaxLineLength:DisableCloudBackupSection.kt:text = { Text("Disabling Cloud Backup will permanently delete your current Cove cloud backups from cloud storage.") } + MaxLineLength:HotWalletNfcScannerSheet.kt:val words = org.bitcoinppl.cove_core.groupedPlainWordsOf(mnemonic = text, groups = GROUPS_OF.toUByte()) + MaxLineLength:LockView.kt:biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + MaxLineLength:LockView.kt:screen == Screen.BIOMETRIC && (auth.type == AuthType.BIOMETRIC || auth.type == AuthType.BOTH) && isBiometricAvailable + MaxLineLength:LockView.kt:text = "Cove needs permissions to use biometric authentication to unlock your wallet. Please open settings and enable biometric authentication." + MaxLineLength:MainActivity.kt:MainActivity$"Some wallet databases couldn't be encrypted. Your wallets still work and encryption will retry on next launch.\n\nIf this persists, please contact feedback@covebitcoinwallet.com" + MaxLineLength:MainActivity.kt:MainActivity$Log.w(TAG, "[STARTUP] bootstrap completed despite timeout — migration warning (if any) was lost and will retry on next launch") + MaxLineLength:MainActivity.kt:MainActivity$Log.w(TAG, "[STARTUP] resumePendingCloudUploadVerification failed before startup routing", error) + MaxLineLength:MainActivityGlobalAlerts.kt:app.loadAndReset(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWELVE, ImportType.MANUAL)))) + MaxLineLength:MainActivityGlobalAlerts.kt:app.loadAndReset(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.MANUAL)))) + MaxLineLength:MainActivityGlobalAlerts.kt:app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWELVE, ImportType.MANUAL)))) + MaxLineLength:MainActivityGlobalAlerts.kt:app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.MANUAL)))) + MaxLineLength:MainActivityGlobalAlerts.kt:app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.NFC)))) + MaxLineLength:MainActivityGlobalAlerts.kt:app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.QR)))) + MaxLineLength:MainActivityGlobalAlerts.kt:val route = RouteFactory().sendSetAmount(selectedWallet, state.address, state.amount) + MaxLineLength:MainActivityStartupViews.kt:"Cove found Cloud Backup data for the selected Google account. This will erase the damaged local data on this device, then verify your passkey during restore." + MaxLineLength:MainActivityStartupViews.kt:"This will permanently delete wallet data on this device. Make sure your recovery phrases are backed up before continuing." + MaxLineLength:MainSettingsScreen.kt:is CloudBackupSettingsRowStatus.DriveUnavailable -> stringResource(R.string.cloud_backup_status_drive_unavailable) + MaxLineLength:MainSettingsScreen.kt:text = { Text("This is a very experimental feature. Use with caution. This is mostly used by developers for testing purposes.") } + MaxLineLength:MoreInfoPopover.kt:importLabels = { importLabelLauncher.launch(arrayOf("text/plain", "application/json", "application/x-jsonlines")) } + MaxLineLength:MoreInfoPopover.kt:snackbarHostState.showSnackbar("Labels imported successfully, but transaction list may need manual refresh") + MaxLineLength:MoreInfoPopover.kt:snackbarHostState.showSnackbar("Unable to export $errorType: ${e.localizedMessage ?: e.message}") + MaxLineLength:NetworkFeeBottomSheet.kt:color = if (isSelected) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.outlineVariant + MaxLineLength:NetworkFeeBottomSheet.kt:color = if (isSelected) MaterialTheme.colorScheme.outlineVariant else MaterialTheme.colorScheme.outline + MaxLineLength:NetworkFeeBottomSheet.kt:containerColor = if (isSelected) MaterialTheme.coveColors.midnightBtn else MaterialTheme.colorScheme.surfaceVariant + MaxLineLength:OnboardingCloudBackupEnableViews.kt:subtitle = "Cloud Backup makes it easier to restore this wallet's configuration and labels if you lose this device." + MaxLineLength:OnboardingCloudBackupEnableViews.kt:text = "Enabling this also turns on Cloud Backup for Cove more broadly, so compatible wallets you create later, as well as wallet labels, will be backed up." + MaxLineLength:OnboardingCloudBackupEnableViews.kt:text = "This backs up the imported hardware wallet configuration and labels stored in Cove so you can restore this wallet view later." + MaxLineLength:OnboardingCloudBackupEnableViews.kt:text = "Your wallet backup is end-to-end encrypted before it leaves your device, stored in Google Drive, and locked with a passkey only you control." + MaxLineLength:OnboardingCloudRestoreProgressViews.kt:text = "${report.labelsFailedWalletNames.size} restored wallet(s) had labels that could not be imported" + MaxLineLength:OnboardingCloudRestorePromptViews.kt:Row + MaxLineLength:OnboardingCloudRestorePromptViews.kt:text = "Your passkey is stored securely by your passkey provider, and your encrypted backup is stored in Google Drive app data." + MaxLineLength:OnboardingManager.kt:OnboardingManager$val nextSnapshot = reduceOnboardingSnapshot(OnboardingSnapshot(state = state, isComplete = isComplete), message) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CloudBackupEnabled -> snapshot.copy(state = snapshot.state.copy(cloudBackupEnabled = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CloudRestoreAlertVisible -> snapshot.copy(state = snapshot.state.copy(cloudRestoreAlertVisible = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CloudRestoreMessageChanged -> snapshot.copy(state = snapshot.state.copy(cloudRestoreMessage = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CloudRestoreProviderHintChanged -> snapshot.copy(state = snapshot.state.copy(cloudRestoreProviderHint = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CloudRestoreState -> snapshot.copy(state = snapshot.state.copy(cloudRestoreState = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.CreatedWords -> snapshot.copy(state = snapshot.state.copy(createdWords = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.ErrorMessageChanged -> snapshot.copy(state = snapshot.state.copy(errorMessage = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.RestoreStateChanged -> snapshot.copy(state = snapshot.state.copy(restoreState = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.SecretWordsSaved -> snapshot.copy(state = snapshot.state.copy(secretWordsSaved = message.v1)) + MaxLineLength:OnboardingManager.kt:is OnboardingReconcileMessage.ShouldOfferCloudRestore -> snapshot.copy(state = snapshot.state.copy(shouldOfferCloudRestore = message.v1)) + MaxLineLength:OnboardingPromptViews.kt:subtitle = "Cove can't check for a Google Drive backup right now. You can continue onboarding and check Cloud Backup later in Settings." + MaxLineLength:OnboardingPromptViews.kt:subtitle = "We couldn't find a Cove backup in Google Drive for this account. Continue without a backup, then set up Cloud Backup when you're ready." + MaxLineLength:OnboardingRecoveryWordsViews.kt:text = "Write these down exactly in order and keep them offline. Anyone with these words can control your Bitcoin." + MaxLineLength:OnboardingTermsViews.kt:text = "I understand that Cove is not a bank, exchange, or licensed financial institution, and does not offer financial services." + MaxLineLength:OnboardingTermsViews.kt:text = "I understand that I am responsible for securely managing and backing up my wallets. Cove does not store or recover wallet information." + MaxLineLength:OnboardingTermsViews.kt:text = "I understand that if I lose access to my wallet, Cove cannot recover my funds or credentials." + MaxLineLength:OtherBackupsSection.kt:OtherBackupsRecoveryResult$add("Your current Cloud Backup passkey is unchanged. Verify your current passkey to make sure it opens your active backup.") + MaxLineLength:OtherBackupsSection.kt:Text("This will use the selected passkey once to decrypt these other backups. Your current Cloud Backup passkey will not change.") + MaxLineLength:OtherBackupsSection.kt:text = "${pluralize(namespaceCount, "backup set", "backup sets")} protected by ${otherPasskeyLabel(passkeySuffixes)}, containing ${pluralize(walletCount, "wallet", "wallets")}" + MaxLineLength:OtherBackupsSection.kt:text = { Text("These backups cannot be recovered later, even if you find the passkey that currently protects them.") } + MaxLineLength:QrCodeScanView.kt:drawLine(color, Offset(size.width - cornerLength, size.height), Offset(size.width, size.height), strokeWidth) + MaxLineLength:QrCodeScanView.kt:drawLine(color, Offset(size.width, size.height - cornerLength), Offset(size.width, size.height), strokeWidth) + MaxLineLength:ReceiveAddressSheet.kt:Text("This address has already received funds. For better privacy, create a new address before sharing.") + MaxLineLength:RouterManager.kt:RouteHelpers$fun + MaxLineLength:SecretWordsScreen.kt:"Your seed words are sensitive and control access to your Bitcoin. QR codes are machine-readable, so be careful who or what device you show this to." + MaxLineLength:SelectedWalletContainer.kt:android.util.Log.d("SelectedWalletContainer", "canGoBack=$canGoBack, routes=${app.router.routes.size}, default=${app.router.default}") + MaxLineLength:SelectedWalletScreen.kt:onToggleSensitive = { manager.dispatch(WalletManagerAction.ToggleSensitiveVisibility) } + MaxLineLength:SelectedWalletScreen.kt:onToggleUnit = { manager.dispatch(WalletManagerAction.ToggleFiatBtcPrimarySecondary) } + MaxLineLength:SendFlowContainer.kt:message = "The wallet could not be opened for sending. Please try again from the wallet screen." + MaxLineLength:SendFlowSetAmountScreen.kt:AsyncText(text = feeAmount, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + MaxLineLength:SidebarContainer.kt:val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) + MaxLineLength:StartupRecovery.kt:"App startup timed out. Please force-quit and try again.\n\nPlease contact feedback@covebitcoinwallet.com" + MaxLineLength:TapCardNfcManager.kt:TapCardNfcManager$// create TapSignerReader using factory function (workaround for UniFFI async constructor limitation) + MaxLineLength:TapSignerAdvancedChainCode.kt:"Enter your custom 32-byte chain code below. If you're unsure, select automatic on the previous screen." + MaxLineLength:TapSignerChooseChainCode.kt:"You can provide your own chain code for advanced setups, or let the app create one automatically for easy setup." + MaxLineLength:TapSignerConfirmPinView.kt:"The PIN code is a security feature that prevents unauthorized access to your key. Please back it up and keep it safe. You'll need it for signing transactions." + MaxLineLength:TapSignerNewPinView.kt:"The PIN code is a security feature that prevents unauthorized access to your key. Please back it up and keep it safe. You'll need it for signing transactions." + MaxLineLength:TapSignerSetupSuccessView.kt:"If you haven't already done so please download your backup and store it in a safe place. You will need this and the backup password on the back of the card to restore your wallet." + MaxLineLength:TransactionDetailsScreen.kt:text = stringResource(if (isExpanded) R.string.btn_hide_details else R.string.btn_show_details) + MaxLineLength:TransactionDetailsWidget.kt:AutoSizeText(primary, color = primaryColor, maxFontSize = 15.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.SemiBold) + MaxLineLength:TransactionsRows.kt:formatAmountFor(fiatOrBtc, AmountPosition.PRIMARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) + MaxLineLength:TransactionsRows.kt:formatAmountFor(fiatOrBtc, AmountPosition.SECONDARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) + MaxLineLength:TransactionsRows.kt:message = "Unable to delete transaction: ${e.localizedMessage ?: e.message ?: "Unknown error"}" + MaxLineLength:TransactionsRows.kt:showsLockedTreatment && type == TransactionType.RECEIVED -> CoveColor.TransactionReceived.copy(alpha = 0.72f) + MaxLineLength:UtxoListScreen.kt:arrowUp = (amountPresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING + MaxLineLength:UtxoListScreen.kt:arrowUp = (changePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING + MaxLineLength:UtxoListScreen.kt:arrowUp = (datePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING + MaxLineLength:UtxoListScreen.kt:arrowUp = (namePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING + MaxLineLength:UtxoListScreen.kt:containerColor = if (anySelected) MaterialTheme.coveColors.midnightBtn else MaterialTheme.colorScheme.surfaceVariant + MaxLineLength:UtxoListScreen.kt:contentColor = if (anySelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant + MaxLineLength:VerificationCompleteScreen.kt:text = "All set! You've successfully verified your recovery words and can now access your wallet." + MaxLineLength:VerificationSection.kt:leadingContent = { Icon(Icons.Default.WarningAmber, contentDescription = null, tint = CoveColor.WarningOrange) } + MaxLineLength:VerifyWordsContainer.kt:"Whoever has your secret words has access to your bitcoin. Please keep these safe and don't show them to anyone else." + MaxLineLength:VerifyWordsScreen.kt:"Are you sure you want to skip verifying words? Without having a backup of these words, you could lose your bitcoin" + MaxLineLength:WalletBalanceHeaderView.kt:contentColor = if (receiveUnavailable) CoveColor.midnightBlue.copy(alpha = 0.6f) else CoveColor.midnightBlue + MaxLineLength:WalletBalanceHeaderView.kt:contentColor = if (sendUnavailable) CoveColor.midnightBlue.copy(alpha = 0.6f) else CoveColor.midnightBlue + MaxLineLength:WalletManager.kt:WalletManager$var + MaxLineLength:WalletSettingsContainer.kt:android.util.Log.e(tag, "failed to leave wallet settings after recovery failure", result.navigationError) + MaxLineLength:WalletSettingsScreen.kt:if + MaxLineLength:WalletSheets.kt:snackbarHostState.showSnackbar("Unable to share labels: ${e.localizedMessage ?: e.message}") + MaxLineLength:WalletSheets.kt:snackbarHostState.showSnackbar("Unable to share transactions: ${e.localizedMessage ?: e.message}") + MaxLineLength:WalletSheets.kt:snackbarHostState.showSnackbar("Unable to share xpub: ${e.localizedMessage ?: e.message}") + NestedBlockDepth:NfcReader.kt:NfcReader$private fun processNdefMessage + PackageNaming:AboutDebugSection.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:AboutSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:AdvancedDetailsBottomSheet.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:AppearanceSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:BackupExportScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:BackupExportUtils.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:BackupImportScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:BackupVerifyScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:BlockExplorerSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:ChangePinView.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:ChooseWalletTypeSheet.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:CoinControlContainer.kt:package org.bitcoinppl.cove.flows.CoinControlFlow + PackageNaming:CoinControlCustomAmountSheet.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:CoinControlSetAmountScreen.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:ColdWalletQrScanScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.cold_wallet + PackageNaming:DecoyPinView.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:EnterAddressView.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:EnterAmountView.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:FiatCurrencySettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:HeaderIcon.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:HiddenPinTextField.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:HotWalletCreateScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:HotWalletImportCard.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:HotWalletImportScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:HotWalletNfcScannerSheet.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:HotWalletQrScannerSheet.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:HotWalletSelectScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:ImportWalletScreen.kt:package org.bitcoinppl.cove.import_wallet + PackageNaming:MainSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:MainSettingsScreenHelpersTest.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:MainSettingsSecuritySection.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:NetworkFeeBottomSheet.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:NetworkSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:NewHotWalletContainer.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:NewPinView.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:NewWalletContainer.kt:package org.bitcoinppl.cove.flows.NewWalletFlow + PackageNaming:NewWalletSelectScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow + PackageNaming:NfcHelpSheet.kt:package org.bitcoinppl.cove.flows.NewWalletFlow + PackageNaming:NodeSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:OnboardingBackupViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingBackupViewsTest.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingCloudBackupEnableViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingCloudRestoreProgressViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingCloudRestorePromptViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingContainer.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingExchangeFundingView.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingImportViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingPromptViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingRecoveryWordsViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingTermsViews.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:OnboardingTheme.kt:package org.bitcoinppl.cove.flows.OnboardingFlow + PackageNaming:PinCirclesView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:QrCodeImportScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.cold_wallet + PackageNaming:ReceiveAddressSheet.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:ReceivedTransactionDetails.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:SecretWordsScreen.kt:package org.bitcoinppl.cove.secret_words + PackageNaming:SelectedWalletContainer.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:SelectedWalletScreen.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:SelectedWalletScreenHelpersTest.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:SendFlowAdvancedDetailsView.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowConfirmScreen.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowContainer.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareDetailsSheet.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareImportRoute.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareScreen.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareSections.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareSheets.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHardwareSigning.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowHeaderView.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowManager.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowPresenter.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SendFlowSetAmountScreen.kt:package org.bitcoinppl.cove.flows.SendFlow + PackageNaming:SettingsContainer.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:SettingsListAllWalletsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:TapSignerAdvancedChainCode.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerChooseChainCode.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerConfirmPinView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerContainer.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerEnterPinView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerImportRetryView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerImportSuccessView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerManager.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerNewPinView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerNfcHelper.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerScanningOverlay.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerSetupRetryView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerSetupSuccessView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TapSignerStartingPinView.kt:package org.bitcoinppl.cove.flows.TapSignerFlow + PackageNaming:TransactionDetailsContainer.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:TransactionDetailsScreen.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:TransactionDetailsWidget.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:TransactionLabelView.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails + PackageNaming:TransactionsCardPreviews.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:TransactionsCardView.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:TransactionsRows.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:TransactionsScanState.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:UtxoListScreen.kt:package org.bitcoinppl.cove.flows.CoinControlFlow + PackageNaming:VerificationCompleteScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:VerifyWordsContainer.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:VerifyWordsScreen.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet + PackageNaming:WalletBalanceHeaderView.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:WalletImportHelpSheet.kt:package org.bitcoinppl.cove.flows.NewWalletFlow.cold_wallet + PackageNaming:WalletMoreOptionsSheet.kt:package org.bitcoinppl.cove.flows.SelectedWalletFlow + PackageNaming:WalletSettingsChangeNameScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:WalletSettingsContainer.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:WalletSettingsScreen.kt:package org.bitcoinppl.cove.flows.SettingsFlow + PackageNaming:WipeDataPinView.kt:package org.bitcoinppl.cove.flows.SettingsFlow + RedundantSuspendModifier:AppManager.kt:AppManager$suspend + RedundantSuspendModifier:CoinControlManager.kt:CoinControlManager$suspend + RedundantSuspendModifier:SendFlowManager.kt:SendFlowManager$suspend + RedundantSuspendModifier:TapSignerImportSuccessView.kt:suspend + RedundantSuspendModifier:WalletManager.kt:WalletManager$suspend + RethrowCaughtException:SidebarContainer.kt:throw e + ReturnCount:AppManager.kt:AppManager$fun cachedWalletManager: WalletManager? + ReturnCount:AppManager.kt:AppManager$internal fun popRouteForRecovery: RoutePopResult + ReturnCount:AppManager.kt:AppManager$private fun pushRouteWithoutNavigationGeneration: Boolean + ReturnCount:AuthManager.kt:AuthManager$fun handleAndReturnUnlockMode: UnlockMode + ReturnCount:CloudBackupManager.kt:CloudBackupManager$private fun reconcileDisabledState + ReturnCount:CloudBackupPresentationHost.kt:CloudBackupPresentationCoordinator$fun reconcile + ReturnCount:CloudBackupPresentationHost.kt:internal fun isCloudBackupPresentationPresentable: Boolean + ReturnCount:CustomFeeRateSheet.kt:fun getTotalSatsDeduped + ReturnCount:DisableCloudBackupSection.kt:private fun disableUnavailableMessage: String? + ReturnCount:DriveAuthorizationHelper.kt:DriveAccountIdentity$fun matches: Boolean + ReturnCount:NfcWriter.kt:NfcWriter$private fun handleTag + ReturnCount:OnboardingCloudBackupEnableViews.kt:internal fun shouldCompleteOnboardingCloudBackup: Boolean + ReturnCount:OnboardingCloudRestoreProgressViews.kt:internal fun combinedRestoreProgress: Float + SwallowedException:BackupExportScreen.kt:e: androidx.credentials.exceptions.NoCredentialException + SwallowedException:BackupImportScreen.kt:e: androidx.credentials.exceptions.NoCredentialException + SwallowedException:BackupVerifyScreen.kt:e: androidx.credentials.exceptions.NoCredentialException + SwallowedException:BlockExplorerSettingsScreen.kt:e: Exception + SwallowedException:CustomFeeRateSheet.kt:e: SendFlowException.WalletManager + SwallowedException:HotWalletImportScreen.kt:e: ImportWalletException.InvalidWordGroup + SwallowedException:NfcReader.kt:NfcReader$e: Exception + SwallowedException:NfcWriter.kt:NfcWriter$closeError: Exception + SwallowedException:ScanManager.kt:ScanManager$e: ImportWalletException.InvalidWordGroup + SwallowedException:SelectedWalletContainer.kt:ex: Exception + SwallowedException:SendFlowAdvancedDetailsView.kt:e: Exception + SwallowedException:TapSignerConfirmPinView.kt:e: Exception + ThrowsCount:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$override suspend fun deleteWalletBackup + ThrowsCount:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$private suspend fun <T> runDriveOperation: T + ThrowsCount:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$private suspend fun verifiedDriveAccessToken: DriveAccessToken + ThrowsCount:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$override fun authenticateWithPrf: ByteArray + ThrowsCount:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$override fun createPasskey: PasskeyRegistrationResult + ThrowsCount:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$override fun discoverAndAuthenticateWithPrf: DiscoveredPasskeyResult + ThrowsCount:BackupExportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupExportScreen + ThrowsCount:BackupImportScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupImportScreen + ThrowsCount:BackupVerifyScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupVerifyScreen + ThrowsCount:DriveAuthorizationHelper.kt:DriveAuthorizationHelper$private suspend fun resolveIfNeeded: AuthorizationResult + ThrowsCount:RustHandleGuard.kt:RustHandleGuard$fun <Handle, T> withHandle: T + ThrowsCount:RustHandleGuard.kt:RustHandleGuard$suspend fun <Handle, T> withHandleSuspend: T + ThrowsCount:TransactionDetailsScreen.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen + ThrowsCount:WalletManager.kt:WalletManager$suspend fun reconcileAfterLabelImportAndWait: Boolean + TooGenericExceptionCaught:AboutDebugSection.kt:error: Throwable + TooGenericExceptionCaught:AboutSettingsScreen.kt:e: Exception + TooGenericExceptionCaught:AboutSettingsScreen.kt:error: Exception + TooGenericExceptionCaught:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$error: Throwable + TooGenericExceptionCaught:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$retryError: Throwable + TooGenericExceptionCaught:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess$retryTokenError: Throwable + TooGenericExceptionCaught:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$error: Exception + TooGenericExceptionCaught:AppManager.kt:AppManager$e: Exception + TooGenericExceptionCaught:AsyncView.kt:e: Exception + TooGenericExceptionCaught:AuthManager.kt:AuthManager$e: Exception + TooGenericExceptionCaught:BackupExportScreen.kt:e: Exception + TooGenericExceptionCaught:BackupExportUtils.kt:e: Exception + TooGenericExceptionCaught:BackupImportScreen.kt:e: Exception + TooGenericExceptionCaught:BackupVerifyScreen.kt:e: Exception + TooGenericExceptionCaught:BlockExplorerSettingsScreen.kt:e: Exception + TooGenericExceptionCaught:ChooseWalletTypeSheet.kt:e: Exception + TooGenericExceptionCaught:CloudBackupManager.kt:CloudBackupManager$error: Exception + TooGenericExceptionCaught:CoinControlContainer.kt:e: Exception + TooGenericExceptionCaught:ColdWalletQrScanScreen.kt:e: Exception + TooGenericExceptionCaught:CoveApplication.kt:CoveApplication$e: Exception + TooGenericExceptionCaught:CustomFeeRateSheet.kt:e: Exception + TooGenericExceptionCaught:DriveAuthorizationHelper.kt:DriveAuthorizationHelper$error: RuntimeException + TooGenericExceptionCaught:HapticFeedbackExt.kt:e: Exception + TooGenericExceptionCaught:HotWalletCreateScreen.kt:e: Exception + TooGenericExceptionCaught:HotWalletImportScreen.kt:e: Exception + TooGenericExceptionCaught:HotWalletNfcScannerSheet.kt:e: Exception + TooGenericExceptionCaught:ImportWalletScreen.kt:e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$e: Exception + TooGenericExceptionCaught:MainActivity.kt:MainActivity$error: Throwable + TooGenericExceptionCaught:MainActivityGlobalAlerts.kt:e: Exception + TooGenericExceptionCaught:MainSettingsSecuritySection.kt:e: Exception + TooGenericExceptionCaught:MoreInfoPopover.kt:e: Exception + TooGenericExceptionCaught:NewHotWalletContainer.kt:e: Exception + TooGenericExceptionCaught:NewWalletSelectScreen.kt:e: Exception + TooGenericExceptionCaught:NfcLabelImportSheet.kt:e: Exception + TooGenericExceptionCaught:NfcReader.kt:NfcReader$e: Exception + TooGenericExceptionCaught:NfcScanSheet.kt:e: Exception + TooGenericExceptionCaught:NfcWriter.kt:NfcWriter$closeError: Exception + TooGenericExceptionCaught:NfcWriter.kt:NfcWriter$e: Exception + TooGenericExceptionCaught:NodeSettingsScreen.kt:e: Exception + TooGenericExceptionCaught:OnboardingExchangeFundingView.kt:error: Exception + TooGenericExceptionCaught:OnboardingImportViews.kt:error: Exception + TooGenericExceptionCaught:QrCodeImportScreen.kt:e: Exception + TooGenericExceptionCaught:QrCodeScanView.kt:e: Exception + TooGenericExceptionCaught:QrExportView.kt:e: Exception + TooGenericExceptionCaught:ScanManager.kt:ScanManager$e: Exception + TooGenericExceptionCaught:ScanManager.kt:ScanManager$selectError: Exception + TooGenericExceptionCaught:SecretWordsScreen.kt:e: Exception + TooGenericExceptionCaught:SelectedWalletContainer.kt:e: Exception + TooGenericExceptionCaught:SelectedWalletContainer.kt:ex: Exception + TooGenericExceptionCaught:SendFlowAdvancedDetailsView.kt:e: Exception + TooGenericExceptionCaught:SendFlowContainer.kt:e: Exception + TooGenericExceptionCaught:SendFlowHardwareImportRoute.kt:e: Exception + TooGenericExceptionCaught:SendFlowHardwareScreen.kt:e: Exception + TooGenericExceptionCaught:SendFlowHardwareSigning.kt:e: Exception + TooGenericExceptionCaught:TapCardNfcManager.kt:TapCardNfcManager$e: Exception + TooGenericExceptionCaught:TapCardNfcManager.kt:TapCardTransport$e: Exception + TooGenericExceptionCaught:TapSignerConfirmPinView.kt:e: Exception + TooGenericExceptionCaught:TapSignerEnterPinView.kt:e: Exception + TooGenericExceptionCaught:TapSignerImportRetryView.kt:e: Exception + TooGenericExceptionCaught:TapSignerImportSuccessView.kt:e: Exception + TooGenericExceptionCaught:TapSignerNfcHelper.kt:TapSignerNfcHelper$e: Exception + TooGenericExceptionCaught:TapSignerSetupRetryView.kt:e: Exception + TooGenericExceptionCaught:TapSignerSetupSuccessView.kt:e: Exception + TooGenericExceptionCaught:TransactionDetailsContainer.kt:e: Exception + TooGenericExceptionCaught:TransactionDetailsScreen.kt:e: Exception + TooGenericExceptionCaught:TransactionLabelView.kt:e: Exception + TooGenericExceptionCaught:TransactionsRows.kt:e: Exception + TooGenericExceptionCaught:UtxoListScreen.kt:e: Exception + TooGenericExceptionCaught:VerificationCompleteScreen.kt:e: Exception + TooGenericExceptionCaught:VerifyWordsContainer.kt:e: Exception + TooGenericExceptionCaught:WalletManager.kt:WalletManager$e: Exception + TooGenericExceptionCaught:WalletMoreOptionsSheet.kt:e: Exception + TooGenericExceptionCaught:WalletSelectionRecovery.kt:navigationError: Exception + TooGenericExceptionCaught:WalletSelectionRecovery.kt:recoveryError: Exception + TooGenericExceptionCaught:WalletSettingsContainer.kt:e: Exception + TooGenericExceptionCaught:WalletSettingsScreen.kt:e: Exception + TooGenericExceptionCaught:WalletSettingsScreen.kt:e: Throwable + TooGenericExceptionCaught:WalletSheets.kt:e: Exception + TooGenericExceptionThrown:CoveApplication.kt:CoveApplication$throw RuntimeException("Failed to initialize app data directory", e) + TooGenericExceptionThrown:CoveApplication.kt:CoveApplication$throw RuntimeException("Failed to initialize security infrastructure", e) + TooGenericExceptionThrown:MoreInfoPopover.kt:throw Exception("Unable to read file") + TooGenericExceptionThrown:SendFlowHardwareImportRoute.kt:throw Exception(TransactionImportErrors.FILE_READ_ERROR) + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("Activity no longer available") + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("Command completed but result extraction failed") + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("NFC is disabled. Please enable it in Settings") + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("NFC not available on this device") + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("NFC scan timed out. Please try again") + TooGenericExceptionThrown:TapCardNfcManager.kt:TapCardNfcManager$throw Exception("Tag doesn't support IsoDep (ISO7816)") + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Backup command: ${response?.javaClass?.simpleName}", ) + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Change command: ${response?.javaClass?.simpleName}", ) + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Derive command: ${response?.javaClass?.simpleName}", ) + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Setup command: ${resp?.javaClass?.simpleName}", ) + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Setup command: ${response?.javaClass?.simpleName}", ) + TooGenericExceptionThrown:TapSignerNfcHelper.kt:TapSignerNfcHelper$throw Exception( "Unexpected response type for Sign command: ${response?.javaClass?.simpleName}", ) + TooManyFunctions:AndroidCloudStorageAccess.kt:AndroidCloudStorageAccess : CloudStorageAccess + TooManyFunctions:AndroidCloudStorageSupport.kt:org.bitcoinppl.cove.cloudbackup.AndroidCloudStorageSupport.kt + TooManyFunctions:AndroidPasskeyProvider.kt:AndroidPasskeyProvider : PasskeyProvider + TooManyFunctions:AndroidPasskeyProvider.kt:org.bitcoinppl.cove.cloudbackup.AndroidPasskeyProvider.kt + TooManyFunctions:AppManager.kt:AppManager : FfiReconcile + TooManyFunctions:AuthManager.kt:AuthManager : AuthManagerReconciler + TooManyFunctions:CloudBackupManager.kt:CloudBackupManager : CloudBackupManagerReconcilerCloseable + TooManyFunctions:CloudBackupPresentationHost.kt:org.bitcoinppl.cove.cloudbackup.CloudBackupPresentationHost.kt + TooManyFunctions:CloudBackupVisuals.kt:org.bitcoinppl.cove.cloudbackup.CloudBackupVisuals.kt + TooManyFunctions:CoinControlManager.kt:CoinControlManager : CoinControlManagerReconcilerCloseable + TooManyFunctions:CustomBaseViews.kt:org.bitcoinppl.cove.views.CustomBaseViews.kt + TooManyFunctions:OnboardingCloudRestorePromptViews.kt:org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingCloudRestorePromptViews.kt + TooManyFunctions:OnboardingImportViews.kt:org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingImportViews.kt + TooManyFunctions:OnboardingTheme.kt:org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingTheme.kt + TooManyFunctions:RouterManager.kt:RouteHelpers + TooManyFunctions:SendFlowManager.kt:SendFlowManager : SendFlowManagerReconcilerCloseable + TooManyFunctions:WalletManager.kt:WalletManager : WalletManagerReconcilerCloseable + UnnecessaryNotNullOperator:TapSignerScanningOverlay.kt:errorMessage!! + UnreachableCode:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$return runBlocking { try { val activity = ForegroundUiBridge.requireActivity() val response = credentialManager.createCredential( activity, CreatePublicKeyCredentialRequest( requestJson = buildCreateRequestJson(rpId, challenge, user), ), ) val registration = response as? CreatePublicKeyCredentialResponse ?: throw passkeyRequestFailed( PasskeyOperation.REGISTRATION, PasskeyFailureReason.UnexpectedCredentialType, ) validatePasskeyRegistrationPrf(registration.registrationResponseJson) PasskeyRegistrationResult( credentialId = extractCreatedCredentialId(registration.registrationResponseJson), providerAaguid = extractCreatedProviderAaguid(registration.registrationResponseJson), registeredPlatform = PasskeyRegistrationPlatform.ANDROID, ) } catch (error: Exception) { if (error is CancellationException) throw error throw mapPasskeyCreateError(error) } } + UnreachableCode:AndroidPasskeyProvider.kt:AndroidPasskeyProvider$return runBlocking { try { val activity = ForegroundUiBridge.requireActivity() val response = credentialManager.getCredential( activity, buildGetCredentialRequest( requestJson = buildAssertionRequestJson(rpId, null, prfSalt, challenge), preferImmediatelyAvailableCredentials = false, ), ) val credential = response.credential as? PublicKeyCredential ?: throw PasskeyException.NoCredentialFound() DiscoveredPasskeyResult( prfOutput = extractPrfOutput(credential.authenticationResponseJson), credentialId = extractCredentialId(credential.authenticationResponseJson), ) } catch (error: Exception) { if (error is CancellationException) throw error throw mapPasskeyGetError(error, PasskeyOperation.DISCOVER_ASSERTION) } } + UnreachableCode:AppManager.kt:AppManager$router = RouterManager(it.router) + UnreachableCode:AuthManager.kt:AuthManager$Instant.ofEpochSecond(it.toLong()) + UnreachableCode:BackupExportUtils.kt:app.alertState = TaggedItem( AppAlertState.General( title = "Backup Saved!", message = "Your backup has been saved successfully!", ), ) + UnreachableCode:CloudBackupDetailContent.kt:ErrorInlineMessage(it) + UnreachableCode:MoreInfoPopover.kt:it.write(data) + UnreachableCode:QrCodeScanView.kt:onComplete(result.data) + UnreachableCode:QrCodeScanView.kt:onProgress(result.progress) + UnreachableCode:QrCodeScanView.kt:result.haptic.trigger(context) + UnreachableCode:QrCodeScanView.kt:val result = scanner.scan(qrData) + UnreachableCode:SendFlowHardwareImportRoute.kt:return Pair(record, parsed) + UnreachableCode:SendFlowHardwareImportRoute.kt:val record = try { db.getTxThrow(txId = parsed.txId()) } catch (e: Exception) { throw IllegalArgumentException(TransactionImportErrors.TRANSACTION_NOT_FOUND, e) } + UnreachableCode:TapSignerNfcHelper.kt:TapSignerNfcHelper$return performTapSignerCmd(TapSignerCmd.Setup(cmd)) { resp -> when (resp) { is TapSignerResponse.Setup -> resp.v1 else -> throw Exception( "Unexpected response type for Setup command: ${resp?.javaClass?.simpleName}", ) } } + UnreachableCode:TapSignerNfcHelper.kt:TapSignerNfcHelper$return performTapSignerCmd(TapSignerCmd.Setup(cmd)) { response -> when (response) { is TapSignerResponse.Setup -> response.v1 else -> throw Exception( "Unexpected response type for Setup command: ${response?.javaClass?.simpleName}", ) } } + UnsafeCallOnNullableType:CloudBackupDetailContent.kt:manager.detail!! + UnsafeCallOnNullableType:CoveApplication.kt:CoveApplication$connectivityMonitor!! + UnsafeCallOnNullableType:CustomBaseViews.kt:height!! + UnsafeCallOnNullableType:CustomBaseViews.kt:titleColor!! + UnsafeCallOnNullableType:HotWalletNfcScannerSheet.kt:errorMessage!! + UnsafeCallOnNullableType:MainActivity.kt:MainActivity$bootstrapError!! + UnsafeCallOnNullableType:NetworkFeeBottomSheet.kt:customFeeOption!! + UnsafeCallOnNullableType:NewHotWalletContainer.kt:manager!! + UnsafeCallOnNullableType:NfcScanSheet.kt:errorMessage!! + UnsafeCallOnNullableType:NfcWriteSheet.kt:errorMessage!! + UnsafeCallOnNullableType:OnboardingExchangeFundingView.kt:addressRaw!! + UnsafeCallOnNullableType:OnboardingExchangeFundingView.kt:addressText!! + UnsafeCallOnNullableType:OnboardingExchangeFundingView.kt:errorMessage!! + UnsafeCallOnNullableType:OnboardingImportViews.kt:errorMessage!! + UnsafeCallOnNullableType:OnboardingImportViews.kt:manager!! + UnsafeCallOnNullableType:QrCodeScanView.kt:barcode.rawBytes!! + UnsafeCallOnNullableType:QrCodeScanView.kt:barcode.rawValue!! + UnsafeCallOnNullableType:QrExportView.kt:error!! + UnsafeCallOnNullableType:SendFlowContainer.kt:walletManager.sendFlowErrorAlert!! + UnsafeCallOnNullableType:TapCardNfcManager.kt:TapCardNfcManager$pendingDisableRunnable!! + UnsafeCallOnNullableType:TapSignerImportSuccessView.kt:error!! + UnsafeCallOnNullableType:TapSignerManager.kt:TapSignerManager$nfc!! + UnsafeCallOnNullableType:TransactionDetailsContainer.kt:detailsError!! + UnsafeCallOnNullableType:TransactionDetailsContainer.kt:error!! + UnsafeCallOnNullableType:TransactionDetailsContainer.kt:manager!! + UnusedParameter:EnterAmountView.kt:onClearAmount: () -> Unit = {} + UnusedParameter:MoreInfoPopover.kt:app: AppManager + UnusedParameter:NewHotWalletContainer.kt:modifier: Modifier = Modifier + UnusedParameter:SelectedWalletScreen.kt:isDarkList: Boolean + UnusedParameter:SendFlowConfirmScreen.kt:sendFlowManager: SendFlowManager + UnusedParameter:SendFlowHardwareSections.kt:tapSigner: TapSigner + UnusedParameter:SendFlowSetAmountScreen.kt:app: AppManager + UnusedParameter:TapSignerAdvancedChainCode.kt:app: AppManager + UnusedParameter:TapSignerConfirmPinView.kt:activity: android.app.Activity + UnusedParameter:TapSignerEnterPinView.kt:activity: android.app.Activity + UnusedParameter:TapSignerEnterPinView.kt:tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner + UnusedParameter:TapSignerImportSuccessView.kt:manager: TapSignerManager + UnusedParameter:TapSignerNewPinView.kt:app: AppManager + UnusedParameter:TapSignerSetupSuccessView.kt:manager: TapSignerManager + UnusedParameter:TapSignerStartingPinView.kt:app: AppManager + UnusedParameter:UtxoListScreen.kt:modifier: Modifier = Modifier + UnusedPrivateFunction:AboutSettingsScreen.kt:@Preview( name = "About Settings", showSystemUi = true, widthDp = 393, heightDp = 852, ) @Composable private fun AboutSettingsScreenPreview + UnusedPrivateFunction:AdvancedDetailsBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun AdvancedDetailsBottomSheetPreview + UnusedPrivateFunction:AdvancedDetailsBottomSheet.kt:@Preview(showBackground = true) @Composable private fun AdvancedDetailsContentPreview + UnusedPrivateFunction:AppManager.kt:AppManager$private fun resetRouteWithoutNavigationGeneration + UnusedPrivateFunction:BitcoinShieldIcon.kt:@Preview(showBackground = true, backgroundColor = 0xFF1A1A2E) @Composable private fun BitcoinShieldIconPreview + UnusedPrivateFunction:ChoiceDialog.kt:@Preview(showSystemUi = true, widthDp = 393, heightDp = 852) @Composable private fun ChoiceAlertDialogNoCancelPreview + UnusedPrivateFunction:ChoiceDialog.kt:@Preview(showSystemUi = true, widthDp = 393, heightDp = 852) @Composable private fun ChoiceAlertDialogPreview + UnusedPrivateFunction:ChooseWalletTypeSheet.kt:@Preview(showBackground = true) @Composable private fun ChooseWalletTypeSheetContentPreview + UnusedPrivateFunction:CloudBackupPreviewData.kt:@Preview( name = "Cloud Backup Detail Dark", widthDp = 393, heightDp = 852, showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES, ) @Composable private fun CloudBackupScreenPreview + UnusedPrivateFunction:CloudBackupPreviewData.kt:@Preview( name = "Cloud Backup Detail Light", widthDp = 393, heightDp = 852, showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_NO, ) @Composable private fun CloudBackupScreenLightPreview + UnusedPrivateFunction:CloudBackupVisuals.kt:@Composable private fun LoadingRow + UnusedPrivateFunction:CloudBackupWalletRows.kt:private fun Network.cloudBackupDisplayName: String + UnusedPrivateFunction:CloudBackupWalletRows.kt:private fun WalletType.cloudBackupDisplayName: String + UnusedPrivateFunction:CoinControlManager.kt:CoinControlManager$private fun <T> withRust: T + UnusedPrivateFunction:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview0 + UnusedPrivateFunction:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview1 + UnusedPrivateFunction:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview2 + UnusedPrivateFunction:ConfirmationIndicatorView.kt:@Preview(showBackground = true) @Composable private fun ConfirmationIndicatorPreview3 + UnusedPrivateFunction:DotMenuView.kt:@Preview @Composable private fun PreviewDotMenuView + UnusedPrivateFunction:HotWalletCreateScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletCreateScreenPreview + UnusedPrivateFunction:HotWalletImportScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletImportScreenPreview + UnusedPrivateFunction:HotWalletSelectScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun HotWalletSelectScreenPreview + UnusedPrivateFunction:ImageButton.kt:@Preview @Composable private fun PreviewImageButton + UnusedPrivateFunction:ImportWalletScreen.kt:@Preview @Composable private fun ImportWalletPreview12 + UnusedPrivateFunction:ImportWalletScreen.kt:@Preview @Composable private fun ImportWalletPreview24 + UnusedPrivateFunction:NetworkFeeBottomSheet.kt:@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun NetworkFeeBottomSheetPreview + UnusedPrivateFunction:NetworkFeeBottomSheet.kt:@Preview(showBackground = true) @Composable private fun CustomNetworkFeeContentPreview + UnusedPrivateFunction:NetworkFeeBottomSheet.kt:@Preview(showBackground = true) @Composable private fun NetworkFeeContentPreview + UnusedPrivateFunction:NewWalletSelectScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun NewWalletSelectScreenPreview + UnusedPrivateFunction:NfcHelpSheet.kt:@Preview(showBackground = true) @Composable private fun NfcHelpSheetPreview + UnusedPrivateFunction:OnboardingCloudRestorePromptViews.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun OnboardingRestoreOfferWithProviderDatePreview + UnusedPrivateFunction:OnboardingCloudRestorePromptViews.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun OnboardingRestoreOfferWithProviderHintPreview + UnusedPrivateFunction:ReceiveAddressSheet.kt:@Preview(showBackground = true, heightDp = 800) @Composable private fun ReceiveAddressSheetPreview + UnusedPrivateFunction:RecoveryWords.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun RecoveryWordsPreview + UnusedPrivateFunction:SelectedWalletScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun SelectedWalletFunctionalPreview + UnusedPrivateFunction:VerificationCompleteScreen.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun VerificationCompleteScreenPreview + UnusedPrivateFunction:VerificationSection.kt:@Composable private fun CancelledVerificationRecoveryContent + UnusedPrivateFunction:WalletBalanceHeaderView.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun WalletBalanceHeaderViewHiddenPreview + UnusedPrivateFunction:WalletBalanceHeaderView.kt:@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun WalletBalanceHeaderViewPreview + UnusedPrivateProperty:TapSignerNfcHelper.kt:TapSignerNfcHelper$private val tapSigner: TapSigner + UnusedPrivateProperty:TransactionsCardView.kt:private const val SCROLL_THRESHOLD_INDEX = 5 + UnusedVariable:CloudBackupPresentationHost.kt:val intent = presentation.intent + UnusedVariable:OnboardingManager.kt:OnboardingManager$val actionType = action::class.simpleName ?: "Unknown" + UnusedVariable:QrCodeScanView.kt:val context = LocalContext.current + UnusedVariable:SelectedWalletScreen.kt:val isScrolled = scrollProgress > 0f + UnusedVariable:SelectedWalletScreen.kt:val listCard = MaterialTheme.colorScheme.surface + UnusedVariable:VerifyWordsScreen.kt:val config = remember { stateMachine.config() } + UnusedVariable:WalletMoreOptionsSheet.kt:val scope = rememberCoroutineScope() + UseCheckOrError:DriveAuthorizationHelper.kt:DriveAuthorizationHelper$throw IllegalStateException("drive authorization succeeded but returned a blank access token") + UseCheckOrError:DriveAuthorizationHelper.kt:DriveAuthorizationHelper$throw IllegalStateException("google drive authorization result is missing intent data") + UseCheckOrError:TapSignerEnterPinView.kt:throw IllegalStateException("Backup not available for this TapSigner") + UseCheckOrError:WalletMoreOptionsSheet.kt:throw IllegalStateException("Backup not available") + UseOrEmpty:CoinControlCustomAmountSheet.kt:enteringAmount ?: "" + UseOrEmpty:NodeSettingsScreen.kt:e.message ?: "" + UseOrEmpty:QrExportView.kt:qrStrings.getOrNull(safeIndex) ?: qrStrings.firstOrNull() ?: "" + UseOrEmpty:SendFlowHardwareSigning.kt:clipData?.getItemAt(0)?.text?.toString() ?: "" + UseOrEmpty:WalletSettingsScreen.kt:manager.masterFingerprint() ?: "" + VarCouldBeVal:AppManager.kt:AppManager$// rust bridge - not observable private var rust: FfiApp = FfiApp() private set + VarCouldBeVal:CoinControlCustomAmountSheet.kt:var isEditing by remember { mutableStateOf(false) } + WildcardImport:AdvancedDetailsBottomSheet.kt:import androidx.compose.foundation.layout.* + WildcardImport:AdvancedDetailsBottomSheet.kt:import androidx.compose.material3.* + WildcardImport:AdvancedDetailsBottomSheet.kt:import androidx.compose.runtime.* + WildcardImport:AppManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:AppManager.kt:import org.bitcoinppl.cove_core.tapcard.* + WildcardImport:AppManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:AppSheetState.kt:import org.bitcoinppl.cove_core.* + WildcardImport:AuthManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:AuthManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:CoinControlContainer.kt:import org.bitcoinppl.cove_core.* + WildcardImport:CoinControlContainer.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:CoinControlCustomAmountSheet.kt:import androidx.compose.foundation.layout.* + WildcardImport:CoinControlCustomAmountSheet.kt:import androidx.compose.material3.* + WildcardImport:CoinControlCustomAmountSheet.kt:import androidx.compose.runtime.* + WildcardImport:CoinControlManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:CoinControlManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:CoinControlSetAmountScreen.kt:import androidx.compose.foundation.layout.* + WildcardImport:CoinControlSetAmountScreen.kt:import androidx.compose.material3.* + WildcardImport:CustomFeeRateSheet.kt:import androidx.compose.foundation.layout.* + WildcardImport:CustomFeeRateSheet.kt:import androidx.compose.material3.* + WildcardImport:CustomFeeRateSheet.kt:import androidx.compose.runtime.* + WildcardImport:FeeRateSelectorSheet.kt:import androidx.compose.foundation.layout.* + WildcardImport:FeeRateSelectorSheet.kt:import androidx.compose.material3.* + WildcardImport:FeeRateSelectorSheet.kt:import androidx.compose.runtime.* + WildcardImport:HotWalletCreateScreen.kt:import org.bitcoinppl.cove_core.* + WildcardImport:HotWalletCreateScreen.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:HotWalletSelectScreen.kt:import org.bitcoinppl.cove_core.* + WildcardImport:HotWalletSelectScreen.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:ImportWalletManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:ImportWalletManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:ImportWalletScreen.kt:import androidx.compose.foundation.layout.* + WildcardImport:ImportWalletScreen.kt:import androidx.compose.material3.* + WildcardImport:ImportWalletScreen.kt:import androidx.compose.runtime.* + WildcardImport:NetworkFeeBottomSheet.kt:import androidx.compose.foundation.layout.* + WildcardImport:NetworkFeeBottomSheet.kt:import androidx.compose.material3.* + WildcardImport:NetworkFeeBottomSheet.kt:import androidx.compose.runtime.* + WildcardImport:PendingWalletManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:PendingWalletManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:QrCodeImportScreen.kt:import androidx.compose.foundation.layout.* + WildcardImport:QrCodeImportScreen.kt:import androidx.compose.material3.* + WildcardImport:QrCodeImportScreen.kt:import androidx.compose.runtime.* + WildcardImport:QrCodeScanView.kt:import androidx.compose.foundation.layout.* + WildcardImport:QrCodeScanView.kt:import androidx.compose.material3.* + WildcardImport:QrCodeScanView.kt:import androidx.compose.runtime.* + WildcardImport:RecoveryWords.kt:import androidx.compose.runtime.* + WildcardImport:RouteView.kt:import org.bitcoinppl.cove_core.* + WildcardImport:RouteView.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:RouterManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:RouterManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:ScanManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:ScanManager.kt:import org.bitcoinppl.cove_core.tapcard.* + WildcardImport:ScanManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:SendFlowConfirmScreen.kt:import androidx.compose.foundation.layout.* + WildcardImport:SendFlowConfirmScreen.kt:import androidx.compose.material3.* + WildcardImport:SendFlowConfirmScreen.kt:import androidx.compose.runtime.* + WildcardImport:SendFlowContainer.kt:import org.bitcoinppl.cove_core.* + WildcardImport:SendFlowContainer.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:SendFlowManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:SendFlowManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:SendFlowPresenter.kt:import org.bitcoinppl.cove_core.* + WildcardImport:SendFlowPresenter.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:SettingsContainer.kt:import org.bitcoinppl.cove_core.* + WildcardImport:SettingsContainer.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:TapCardNfcManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:TapSignerNfcHelper.kt:import org.bitcoinppl.cove_core.* + WildcardImport:VerificationCompleteScreen.kt:import org.bitcoinppl.cove_core.* + WildcardImport:VerificationCompleteScreen.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:WalletColorExt.kt:import org.bitcoinppl.cove_core.* + WildcardImport:WalletColorExt.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:WalletManager.kt:import org.bitcoinppl.cove_core.* + WildcardImport:WalletManager.kt:import org.bitcoinppl.cove_core.types.* + WildcardImport:WalletSettingsContainer.kt:import org.bitcoinppl.cove_core.* + WildcardImport:WalletSettingsContainer.kt:import org.bitcoinppl.cove_core.types.* + + diff --git a/android/detekt.yml b/android/detekt.yml new file mode 100644 index 000000000..3be70b14b --- /dev/null +++ b/android/detekt.yml @@ -0,0 +1,17 @@ +complexity: + CyclomaticComplexMethod: + active: true + LargeClass: + active: true + LongMethod: + active: true + +exceptions: + SwallowedException: + active: true + TooGenericExceptionCaught: + active: true + +potential-bugs: + UnsafeCallOnNullableType: + active: true diff --git a/ios/Cove.xcodeproj/project.pbxproj b/ios/Cove.xcodeproj/project.pbxproj index 8fc7f4561..9bd447744 100644 --- a/ios/Cove.xcodeproj/project.pbxproj +++ b/ios/Cove.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ C0B000122F00000000000001 /* TransactionsScanUiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000132F00000000000001 /* TransactionsScanUiTests.swift */; }; C0B000142F00000000000001 /* OnboardingBackupViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000152F00000000000001 /* OnboardingBackupViewsTests.swift */; }; C0B000162F00000000000001 /* CustomFeeRateErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000172F00000000000001 /* CustomFeeRateErrorTests.swift */; }; + C0B000182F00000000000001 /* ICloudDriveHelperDecompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000192F00000000000001 /* ICloudDriveHelperDecompositionTests.swift */; }; + C0B000192F10000000000001 /* AmountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000182F10000000000001 /* AmountFormatterTests.swift */; }; + C0B0001B2F10000000000001 /* NavigationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +65,9 @@ C0B000132F00000000000001 /* TransactionsScanUiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsScanUiTests.swift; sourceTree = ""; }; C0B000152F00000000000001 /* OnboardingBackupViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingBackupViewsTests.swift; sourceTree = ""; }; C0B000172F00000000000001 /* CustomFeeRateErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFeeRateErrorTests.swift; sourceTree = ""; }; + C0B000192F00000000000001 /* ICloudDriveHelperDecompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudDriveHelperDecompositionTests.swift; sourceTree = ""; }; + C0B000182F10000000000001 /* AmountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountFormatterTests.swift; sourceTree = ""; }; + C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatorTests.swift; sourceTree = ""; }; D0245F4C2C0F7B4E0042B447 /* Cove.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cove.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -120,8 +126,11 @@ C0B000062F00000000000001 /* CoveTests */ = { isa = PBXGroup; children = ( + C0B000182F10000000000001 /* AmountFormatterTests.swift */, C0B000112F00000000000001 /* CloudBackupPresentationCoordinatorTests.swift */, C0B000172F00000000000001 /* CustomFeeRateErrorTests.swift */, + C0B000192F00000000000001 /* ICloudDriveHelperDecompositionTests.swift */, + C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */, C0B000152F00000000000001 /* OnboardingBackupViewsTests.swift */, C0B000032F00000000000001 /* OnboardingPromptLayoutTests.swift */, C0B000132F00000000000001 /* TransactionsScanUiTests.swift */, @@ -306,8 +315,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C0B000192F10000000000001 /* AmountFormatterTests.swift in Sources */, C0B000102F00000000000001 /* CloudBackupPresentationCoordinatorTests.swift in Sources */, C0B000162F00000000000001 /* CustomFeeRateErrorTests.swift in Sources */, + C0B000182F00000000000001 /* ICloudDriveHelperDecompositionTests.swift in Sources */, + C0B0001B2F10000000000001 /* NavigationCoordinatorTests.swift in Sources */, C0B000142F00000000000001 /* OnboardingBackupViewsTests.swift in Sources */, C0B000012F00000000000001 /* OnboardingPromptLayoutTests.swift in Sources */, C0B000122F00000000000001 /* TransactionsScanUiTests.swift in Sources */, diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index e3532ef7a..0512f77e5 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -1,29 +1,25 @@ import MijickPopups import Observation import SwiftUI -import UIKit private let walletModeChangeDelayMs = 250 -private let sidebarNavigationDelayMs = 250 -private let navigationSettleDelayMs = 800 @Observable final class AppManager: FfiReconcile { static let shared = makeShared() private let logger = Log(id: "AppManager") - @ObservationIgnored - private let navigationGenerations = GenerationTracker() - @ObservationIgnored - private var pendingSidebarNavigationTask: Task? - @ObservationIgnored - private var navigationSettleTask: Task? var rust: FfiApp var router: Router var database: Database + var navigationCoordinator: NavigationCoordinator + var managerCache: ManagerCache var wallets: [WalletMetadata] = [] var isSidebarVisible = false - var isNavigationSettled = true + var isNavigationSettled: Bool { + navigationCoordinator.isNavigationSettled + } + var asyncRuntimeReady = false var alertState: TaggedItem? = .none @@ -53,19 +49,17 @@ private let navigationSettleDelayMs = 800 var routeId = UUID() /// AppManager is the sole owner of the live wallet manager used by wallet-backed routes. - private(set) var walletManager: WalletManager? - /// Background time is tied to the cached wallet manager, not a route instance - @ObservationIgnored - private var initialScanBackgroundTask: UIBackgroundTaskIdentifier = .invalid - @ObservationIgnored - private weak var initialScanBackgroundTaskWalletManager: WalletManager? - @ObservationIgnored - private var initialScanBackgroundTaskAllowed = false + var walletManager: WalletManager? { + managerCache.walletManager + } - private(set) var sendFlowManager: SendFlowManager? + var sendFlowManager: SendFlowManager? { + managerCache.sendFlowManager + } - @ObservationIgnored - weak var coinControlManager: CoinControlManager? + var coinControlManager: CoinControlManager? { + managerCache.coinControlManager + } public var colorScheme: ColorScheme? { switch colorSchemeSelection { @@ -100,6 +94,8 @@ private let navigationSettleDelayMs = 800 router = state.router self.rust = rust + navigationCoordinator = NavigationCoordinator(routeClient: rust) + managerCache = ManagerCache(backgroundScanTaskHandler: BackgroundScanTaskHandler()) database = Database() wallets = (try? database.wallets().all()) ?? [] needsOnboarding = rust.needsOnboarding() @@ -119,179 +115,47 @@ private let navigationSettleDelayMs = 800 } func cachedWalletManager(id: WalletId) -> WalletManager? { - guard let walletManager, walletManager.id == id else { return nil } - return walletManager + managerCache.cachedWalletManager(id: id) } func walletMetadata(id: WalletId) -> WalletMetadata? { - if let walletManager = cachedWalletManager(id: id) { - return walletManager.walletMetadata - } - - return wallets.first(where: { $0.id == id }) + managerCache.walletMetadata(id: id, wallets: wallets) } func ensureWalletManager(id: WalletId) throws -> WalletManager { - if let walletManager = cachedWalletManager(id: id) { - logger.debug("found and using vm for \(id)") - return walletManager - } - - logger.debug( - "did not find vm for \(id), creating new vm: \(walletManager?.id ?? "none")" - ) - clearWalletManager() - - let walletManager = try WalletManager(id: id) - observeInitialScanLifecycle(for: walletManager) - self.walletManager = walletManager - return walletManager - } - - private func observeInitialScanLifecycle(for walletManager: WalletManager) { - walletManager.setInitialScanLifecycleChanged { [weak self, weak walletManager] in - DispatchQueue.main.async { [weak self, weak walletManager] in - guard let self, let walletManager else { return } - guard self.walletManager === walletManager else { return } - - self.updateInitialScanBackgroundTask() - } - } + try managerCache.ensureWalletManager(id: id, delegate: self) } func beginInitialScanBackgroundTaskIfNeeded() { - initialScanBackgroundTaskAllowed = true - updateInitialScanBackgroundTask() + managerCache.beginInitialScanBackgroundTaskIfNeeded() } func endInitialScanBackgroundTask() { - initialScanBackgroundTaskAllowed = false - endInitialScanBackgroundTaskHandle() - } - - private func updateInitialScanBackgroundTask() { - guard let walletManager, walletManager.activeIncompleteInitialScan else { - endInitialScanBackgroundTaskHandle() - return - } - - guard initialScanBackgroundTaskAllowed else { - endInitialScanBackgroundTaskHandle() - return - } - - guard initialScanBackgroundTask == .invalid else { - endInitialScanBackgroundTaskIfInactive() - return - } - - let backgroundTask = UIApplication.shared.beginBackgroundTask( - withName: "Initial wallet scan" - ) { [weak self] in - DispatchQueue.main.async { [weak self] in - Log.warn("Initial wallet scan background task expired") - self?.endInitialScanBackgroundTask() - } - } - - guard backgroundTask != .invalid else { - Log.warn("Unable to start initial wallet scan background task") - return - } - - initialScanBackgroundTask = backgroundTask - initialScanBackgroundTaskWalletManager = walletManager - logger.debug("Started initial wallet scan background task for wallet \(walletManager.id)") - - endInitialScanBackgroundTaskIfInactive() - } - - private func endInitialScanBackgroundTaskHandle() { - guard initialScanBackgroundTask != .invalid else { return } - - let backgroundTask = initialScanBackgroundTask - initialScanBackgroundTask = .invalid - initialScanBackgroundTaskWalletManager = nil - - UIApplication.shared.endBackgroundTask(backgroundTask) - logger.debug("Ended initial wallet scan background task") - } - - private func endInitialScanBackgroundTaskIfInactive() { - guard initialScanBackgroundTask != .invalid else { return } - guard let walletManager, - let initialScanBackgroundTaskWalletManager, - walletManager === initialScanBackgroundTaskWalletManager, - walletManager.activeIncompleteInitialScan - else { - endInitialScanBackgroundTaskHandle() - return - } + managerCache.endInitialScanBackgroundTask() } func cachedSendFlowManager(id: WalletId) -> SendFlowManager? { - guard let sendFlowManager, sendFlowManager.id == id else { return nil } - return sendFlowManager + managerCache.cachedSendFlowManager(id: id) } func ensureSendFlowManager( _ walletManager: WalletManager, presenter: SendFlowPresenter ) throws -> SendFlowManager { - if let sendFlowManager = cachedSendFlowManager(id: walletManager.id) { - logger.debug("found and using sendflow manager for \(walletManager.id)") - sendFlowManager.presenter = presenter - return sendFlowManager - } - - logger.debug("did not find SendFlowManager for \(walletManager.id), creating new") - clearSendFlowManager() - - let sendFlowManager = try SendFlowManager( - walletManager.rust.newSendFlowManager(balance: walletManager.balance), - presenter: presenter - ) - self.sendFlowManager = sendFlowManager - return sendFlowManager + try managerCache.ensureSendFlowManager(walletManager, presenter: presenter) } public func setCoinControlManager(_ manager: CoinControlManager) { - coinControlManager = manager + managerCache.setCoinControlManager(manager) } public func clearCoinControlManager(_ manager: CoinControlManager) { - if coinControlManager === manager { - coinControlManager = nil - } - } - - private func clearCoinControlManager() { - guard let coinControlManager else { return } - - self.coinControlManager = nil - coinControlManager.close() - } - - private func reconcileCoinControlManagerOwnership() { - guard coinControlManager != nil else { return } - guard !router.containsCoinControlRoute else { return } - - clearCoinControlManager() + managerCache.clearCoinControlManager(manager) } @MainActor public func reconcileAfterLabelsChanged(walletId: WalletId) { - if let walletManager, walletManager.id == walletId { - walletManager.reconcileAfterLabelsChanged() - } - - if let coinControlManager, coinControlManager.id == walletId { - Task { await coinControlManager.reloadLabels() } - } - - if let sendFlowManager, sendFlowManager.id == walletId { - sendFlowManager.reconcileAfterLabelsChanged() - } + managerCache.reconcileAfterLabelsChanged(walletId: walletId) } public var fullVersionId: String { @@ -301,28 +165,11 @@ private let navigationSettleDelayMs = 800 } func clearWalletManager(id: WalletId? = nil) { - if id == nil { - endInitialScanBackgroundTask() - walletManager?.setInitialScanLifecycleChanged(nil) - walletManager?.close() - walletManager = nil - clearSendFlowManager() - return - } - - if walletManager?.id == id { - endInitialScanBackgroundTask() - walletManager?.setInitialScanLifecycleChanged(nil) - walletManager?.close() - walletManager = nil - } - - clearSendFlowManager(id: id) + managerCache.clearWalletManager(id: id) } func clearSendFlowManager(id: WalletId? = nil) { - guard id == nil || sendFlowManager?.id == id else { return } - sendFlowManager = nil + managerCache.clearSendFlowManager(id: id) } public func findTapSignerWallet(_ ts: TapSigner) -> WalletMetadata? { @@ -339,17 +186,12 @@ private let navigationSettleDelayMs = 800 /// Reset the manager state public func reset() { - pendingSidebarNavigationTask?.cancel() - pendingSidebarNavigationTask = nil - navigationSettleTask?.cancel() - navigationSettleTask = nil - advanceNavigationGeneration() + navigationCoordinator.reset() database = Database() needsOnboarding = rust.needsOnboarding() clearWalletManager() - coinControlManager?.close() - coinControlManager = nil + managerCache.clearCoinControlManager() let state = rust.state() router = state.router @@ -365,10 +207,6 @@ private let navigationSettleDelayMs = 800 router.routes.last ?? router.default } - private func isDuplicateTopRoute(_ route: Route) -> Bool { - currentRoute.isSameNavigationDestination(routeToCheck: route) - } - var hasWallets: Bool { rust.hasWallets() } @@ -387,13 +225,11 @@ private let navigationSettleDelayMs = 800 } func selectWalletOrThrow(_ id: WalletId) throws { - advanceNavigationGeneration() - try selectWalletWithoutNavigationGeneration(id) - } - - private func selectWalletWithoutNavigationGeneration(_ id: WalletId) throws { - try rust.dispatch(action: .selectWallet(id: id)) - isSidebarVisible = false + try navigationCoordinator.selectWallet( + id, + router: router, + isSidebarVisible: &isSidebarVisible + ) } func trySelectLatestOrNewWallet() { @@ -405,9 +241,7 @@ private let navigationSettleDelayMs = 800 } func selectLatestOrNewWallet() throws { - advanceNavigationGeneration() - try rust.dispatch(action: .selectLatestOrNewWallet) - isSidebarVisible = false + try navigationCoordinator.selectLatestOrNewWallet(isSidebarVisible: &isSidebarVisible) } func toggleSidebar() { @@ -419,127 +253,71 @@ private let navigationSettleDelayMs = 800 } func pushRoute(_ route: Route) { - guard !isDuplicateTopRoute(route) else { - isSidebarVisible = false - return + navigationCoordinator.pushRoute( + route, + router: &router, + isSidebarVisible: &isSidebarVisible + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) } - - advanceNavigationGeneration() - pushRouteWithoutNavigationGeneration(route) - } - - private func pushRouteWithoutNavigationGeneration(_ route: Route) { - isSidebarVisible = false - guard !isDuplicateTopRoute(route) else { return } - - router.routes.append(route) - reconcileCoinControlManagerOwnership() } func pushRoutes(_ routes: [Route]) { - advanceNavigationGeneration() - pushRoutesWithoutNavigationGeneration(routes) - } - - private func pushRoutesWithoutNavigationGeneration(_ routes: [Route]) { - isSidebarVisible = false - router.routes.append(contentsOf: routes) - reconcileCoinControlManagerOwnership() + navigationCoordinator.pushRoutes( + routes, + router: &router, + isSidebarVisible: &isSidebarVisible + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } } func popRoute() { - advanceNavigationGeneration() - - if !router.routes.isEmpty { - router.routes.removeLast() - reconcileCoinControlManagerOwnership() + navigationCoordinator.popRoute(router: &router) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) } } func setRoute(_ routes: [Route]) { - advanceNavigationGeneration() - router.routes = routes - reconcileCoinControlManagerOwnership() + navigationCoordinator.setRoute(routes, router: &router) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } } func scanQr() { - advanceNavigationGeneration() + navigationCoordinator.advanceNavigationGeneration() sheetState = TaggedItem(.qr) } func scanNfc() { - advanceNavigationGeneration() - scanNfcWithoutNavigationGeneration() - } - - private func scanNfcWithoutNavigationGeneration() { - nfcReader.scan() + navigationCoordinator.scanNfc { + self.nfcReader.scan() + } } @MainActor func resetRoute(to routes: [Route]) { - advanceNavigationGeneration() - resetRouteWithoutNavigationGeneration(to: routes) - } - - @MainActor - private func resetRouteWithoutNavigationGeneration(to routes: [Route]) { - if routes.count > 1 { - rust.resetNestedRoutesTo(defaultRoute: routes[0], nestedRoutes: Array(routes[1...])) - } else if let route = routes.first { - rust.resetDefaultRouteTo(route: route) - } + navigationCoordinator.resetRoute(to: routes) } func resetRoute(to route: Route) { - advanceNavigationGeneration() - resetRouteWithoutNavigationGeneration(to: route) - } - - private func resetRouteWithoutNavigationGeneration(to route: Route) { - rust.resetDefaultRouteTo(route: route) + navigationCoordinator.resetRoute(to: route) } @MainActor func loadAndReset(to route: Route) { - advanceNavigationGeneration() - rust.loadAndResetDefaultRoute(route: route) - } - - @discardableResult - private func advanceNavigationGeneration() -> GenerationToken { - let generation = navigationGenerations.advance() - scheduleNavigationSettled(for: generation) - return generation - } - - private func scheduleNavigationSettledForCurrentGeneration() { - scheduleNavigationSettled(for: navigationGenerations.capture()) - } - - private func scheduleNavigationSettled(for generation: GenerationToken) { - navigationSettleTask?.cancel() - isNavigationSettled = false - - navigationSettleTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: .milliseconds(navigationSettleDelayMs)) - } catch { - return - } - - guard let self else { return } - guard self.isNavigationGenerationCurrent(generation) else { return } - - self.isNavigationSettled = true - self.navigationSettleTask = nil - } + navigationCoordinator.loadAndReset(to: route) } func closeSidebarAndSelectWallet(_ id: WalletId) { closeSidebarThenNavigate { do { - try self.selectWalletWithoutNavigationGeneration(id) + try self.navigationCoordinator.selectWallet( + id, + router: self.router, + isSidebarVisible: &self.isSidebarVisible, + advancesGeneration: false + ) } catch { Log.error("Unable to select wallet \(id), error: \(error)") } @@ -549,85 +327,94 @@ private let navigationSettleDelayMs = 800 func closeSidebarAndOpenNewWallet() { closeSidebarThenNavigate { if self.hasWallets { - self.pushRouteWithoutNavigationGeneration(RouteFactory().newWalletSelect()) + self.navigationCoordinator.pushRoute( + RouteFactory().newWalletSelect(), + router: &self.router, + isSidebarVisible: &self.isSidebarVisible, + advancesGeneration: false + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } } else { - self.resetRouteWithoutNavigationGeneration(to: [RouteFactory().newWalletSelect()]) + self.navigationCoordinator.resetRoute( + to: [RouteFactory().newWalletSelect()], + advancesGeneration: false + ) } } } func closeSidebarAndOpenSettings() { closeSidebarThenNavigate { - self.pushRouteWithoutNavigationGeneration(.settings(.main)) + self.navigationCoordinator.pushRoute( + .settings(.main), + router: &self.router, + isSidebarVisible: &self.isSidebarVisible, + advancesGeneration: false + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } } } func closeSidebarAndOpenWalletSettings(_ id: WalletId) { closeSidebarThenNavigate { - self.pushRoutesWithoutNavigationGeneration(RouteFactory().nestedWalletSettings(id: id)) + self.navigationCoordinator.pushRoutes( + RouteFactory().nestedWalletSettings(id: id), + router: &self.router, + isSidebarVisible: &self.isSidebarVisible, + advancesGeneration: false + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } } } func closeSidebarAndScanNfc() { closeSidebarThenNavigate { - self.scanNfcWithoutNavigationGeneration() + self.navigationCoordinator.scanNfc(advancesGeneration: false) { + self.nfcReader.scan() + } } } private func closeSidebarThenNavigate(_ action: @escaping @MainActor () -> Void) { - pendingSidebarNavigationTask?.cancel() - let generation = advanceNavigationGeneration() - - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - isSidebarVisible = false - } - - pendingSidebarNavigationTask = Task { @MainActor in - do { - try await Task.sleep(for: .milliseconds(sidebarNavigationDelayMs)) - } catch { - return - } - - guard isNavigationGenerationCurrent(generation) else { return } - action() - } + navigationCoordinator.closeSidebarThenNavigate( + isSidebarVisible: &isSidebarVisible, + action: action + ) } @MainActor func captureLoadAndResetGeneration() -> GenerationToken { - navigationGenerations.capture() + navigationCoordinator.captureLoadAndResetGeneration() } @MainActor func startLoadAndResetTargetPrewarm(generation: GenerationToken, routes: [Route]) { - Task { [weak self] in - await self?.prewarmLoadAndResetTargetIfCurrent(generation: generation, routes: routes) - } - } - - @MainActor - func prewarmLoadAndResetTargetIfCurrent(generation: GenerationToken, routes: [Route]) async { - guard isNavigationGenerationCurrent(generation) else { return } - guard case let .selectedWallet(id) = routes.first else { return } + navigationCoordinator.startLoadAndResetTargetPrewarm( + generation: generation, + routes: routes + ) { [weak self] id in + guard let self else { return } - do { - let manager = try ensureWalletManager(id: id) - try await manager.startWalletScanIfNeeded() - } catch { - logger.error("Unable to prewarm selected wallet \(id): \(error)") + do { + let manager = try self.ensureWalletManager(id: id) + try await manager.startWalletScanIfNeeded() + } catch { + self.logger.error("Unable to prewarm selected wallet \(id): \(error)") + } } } @MainActor func resetAfterLoadingIfCurrent(generation: GenerationToken, route: Route, nextRoute: [Route]) { - guard isNavigationGenerationCurrent(generation) else { return } - guard router.default == route else { return } - rust.resetAfterLoading(to: nextRoute) - } - - private func isNavigationGenerationCurrent(_ generation: GenerationToken) -> Bool { - navigationGenerations.isCurrent(capturedToken: generation) + navigationCoordinator.resetAfterLoadingIfCurrent( + generation: generation, + route: route, + nextRoute: nextRoute, + router: router + ) } func reconcile(message: AppStateReconcileMessage) { @@ -637,24 +424,22 @@ private let navigationSettleDelayMs = 800 switch message { case let .routeUpdated(routes: routes): - let didChangeRoute = router.routes != routes - router.routes = routes - reconcileCoinControlManagerOwnership() - - if didChangeRoute { - scheduleNavigationSettledForCurrentGeneration() + navigationCoordinator.applyRouteUpdated( + routes: routes, + router: &router + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) } case let .pushedRoute(route): - guard !isDuplicateTopRoute(route) else { - isSidebarVisible = false - return + navigationCoordinator.applyPushedRoute( + route, + router: &router, + isSidebarVisible: &isSidebarVisible + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) } - router.routes.append(route) - reconcileCoinControlManagerOwnership() - scheduleNavigationSettledForCurrentGeneration() - case .databaseUpdated: database = Database() needsOnboarding = rust.needsOnboarding() @@ -670,11 +455,14 @@ private let navigationSettleDelayMs = 800 loadWallets() case let .defaultRouteChanged(route, nestedRoutes): - router.routes = nestedRoutes - router.default = route - routeId = UUID() - reconcileCoinControlManagerOwnership() - scheduleNavigationSettledForCurrentGeneration() + navigationCoordinator.applyDefaultRouteChanged( + route: route, + nestedRoutes: nestedRoutes, + router: &router, + routeId: &routeId + ) { router in + self.managerCache.reconcileCoinControlManagerOwnership(router: router) + } case let .fiatPricesChanged(prices): self.prices = prices @@ -729,15 +517,8 @@ private let navigationSettleDelayMs = 800 } } -private extension Router { - var containsCoinControlRoute: Bool { - self.default.isCoinControlRoute || routes.contains { $0.isCoinControlRoute } - } -} - -private extension Route { - var isCoinControlRoute: Bool { - if case .coinControl = self { return true } - return false +extension AppManager: WalletManagerDelegate { + func showWalletAlert(_ alertState: AppAlertState) { + self.alertState = .init(alertState) } } diff --git a/ios/Cove/BackgroundScanTaskHandler.swift b/ios/Cove/BackgroundScanTaskHandler.swift new file mode 100644 index 000000000..fc93e6da8 --- /dev/null +++ b/ios/Cove/BackgroundScanTaskHandler.swift @@ -0,0 +1,93 @@ +import UIKit + +final class BackgroundScanTaskHandler { + private let logger = Log(id: "BackgroundScanTaskHandler") + + private var initialScanBackgroundTask: UIBackgroundTaskIdentifier = .invalid + private weak var initialScanBackgroundTaskWalletManager: WalletManager? + private var initialScanBackgroundTaskAllowed = false + + func observeInitialScanLifecycle( + for walletManager: WalletManager, + currentWalletManager: @escaping () -> WalletManager? + ) { + walletManager.setInitialScanLifecycleChanged { [weak self, weak walletManager] in + DispatchQueue.main.async { [weak self, weak walletManager] in + guard let self, let walletManager else { return } + guard currentWalletManager() === walletManager else { return } + + self.updateInitialScanBackgroundTask(walletManager: walletManager) + } + } + } + + func beginInitialScanBackgroundTaskIfNeeded(walletManager: WalletManager?) { + initialScanBackgroundTaskAllowed = true + updateInitialScanBackgroundTask(walletManager: walletManager) + } + + func endInitialScanBackgroundTask() { + initialScanBackgroundTaskAllowed = false + endInitialScanBackgroundTaskHandle() + } + + func updateInitialScanBackgroundTask(walletManager: WalletManager?) { + guard let walletManager, walletManager.activeIncompleteInitialScan else { + endInitialScanBackgroundTaskHandle() + return + } + + guard initialScanBackgroundTaskAllowed else { + endInitialScanBackgroundTaskHandle() + return + } + + guard initialScanBackgroundTask == .invalid else { + endInitialScanBackgroundTaskIfInactive(walletManager: walletManager) + return + } + + let backgroundTask = UIApplication.shared.beginBackgroundTask( + withName: "Initial wallet scan" + ) { [weak self] in + DispatchQueue.main.async { [weak self] in + Log.warn("Initial wallet scan background task expired") + self?.endInitialScanBackgroundTask() + } + } + + guard backgroundTask != .invalid else { + Log.warn("Unable to start initial wallet scan background task") + return + } + + initialScanBackgroundTask = backgroundTask + initialScanBackgroundTaskWalletManager = walletManager + logger.debug("Started initial wallet scan background task for wallet \(walletManager.id)") + + endInitialScanBackgroundTaskIfInactive(walletManager: walletManager) + } + + private func endInitialScanBackgroundTaskHandle() { + guard initialScanBackgroundTask != .invalid else { return } + + let backgroundTask = initialScanBackgroundTask + initialScanBackgroundTask = .invalid + initialScanBackgroundTaskWalletManager = nil + + UIApplication.shared.endBackgroundTask(backgroundTask) + logger.debug("Ended initial wallet scan background task") + } + + private func endInitialScanBackgroundTaskIfInactive(walletManager: WalletManager?) { + guard initialScanBackgroundTask != .invalid else { return } + guard let walletManager, + let initialScanBackgroundTaskWalletManager, + walletManager === initialScanBackgroundTaskWalletManager, + walletManager.activeIncompleteInitialScan + else { + endInitialScanBackgroundTaskHandle() + return + } + } +} diff --git a/ios/Cove/CloudBackupManager.swift b/ios/Cove/CloudBackupManager.swift index 648086ec4..fd059f160 100644 --- a/ios/Cove/CloudBackupManager.swift +++ b/ios/Cove/CloudBackupManager.swift @@ -4,7 +4,7 @@ import SwiftUI extension WeakReconciler: CloudBackupManagerReconciler where Reconciler == CloudBackupManager {} @Observable -final class CloudBackupManager: AnyReconciler, CloudBackupManagerReconciler, @unchecked Sendable { +final class CloudBackupManager: ReconcilingManager, CloudBackupManagerReconciler, @unchecked Sendable { static let shared = CloudBackupManager() private static let staleVerificationThreshold: TimeInterval = 60 * 60 * 24 * 30 @@ -254,7 +254,7 @@ final class CloudBackupManager: AnyReconciler, CloudBackupManagerReconciler, @un dispatch(.startVerification(source)) } - private func apply(_ message: Message) { + func apply(_ message: Message) { switch message { case let .lifecycle(lifecycle, settingsRowStatus): state.lifecycle = lifecycle @@ -263,16 +263,4 @@ final class CloudBackupManager: AnyReconciler, CloudBackupManagerReconciler, @un enableCompletion = TaggedItem(context) } } - - func reconcile(message: Message) { - DispatchQueue.main.async { [weak self] in - self?.apply(message) - } - } - - func reconcileMany(messages: [Message]) { - DispatchQueue.main.async { [weak self] in - messages.forEach { self?.apply($0) } - } - } } diff --git a/ios/Cove/CoinControlManager.swift b/ios/Cove/CoinControlManager.swift index 6b5af2d44..725af8e81 100644 --- a/ios/Cove/CoinControlManager.swift +++ b/ios/Cove/CoinControlManager.swift @@ -11,7 +11,7 @@ private enum CoinControlManagerError: LocalizedError { } } -@Observable final class CoinControlManager: AnyReconciler, CoinControlManagerReconciler { +@Observable final class CoinControlManager: ReconcilingManager, CoinControlManagerReconciler { typealias Message = CoinControlManagerReconcileMessage typealias Action = CoinControlManagerAction @@ -155,7 +155,11 @@ private enum CoinControlManagerError: LocalizedError { } } - private func apply(_ message: Message) { + var canApplyReconcileMessages: Bool { + rust != nil + } + + func apply(_ message: Message) { switch message { case let .updateSort(sort): withAnimation { self.sort = sort } @@ -201,20 +205,12 @@ private enum CoinControlManagerError: LocalizedError { try await rust.setUtxoSpendability(outpoint: outpoint, spendable: spendable) } - func reconcile(message: Message) { - DispatchQueue.main.async { [weak self] in - guard let self, self.rust != nil else { return } - logger.debug("reconcile: \(message)") - apply(message) - } + func logReconcile(message: Message) { + logger.debug("reconcile: \(message)") } - func reconcileMany(messages: [Message]) { - DispatchQueue.main.async { [weak self] in - guard let self, self.rust != nil else { return } - logger.debug("reconcile_messages: \(messages)") - messages.forEach { self.apply($0) } - } + func logReconcileMany(messages: [Message]) { + logger.debug("reconcile_messages: \(messages)") } public func dispatch(action: Action) { diff --git a/ios/Cove/CoveMainAlerts.swift b/ios/Cove/CoveMainAlerts.swift new file mode 100644 index 000000000..df89a64ee --- /dev/null +++ b/ios/Cove/CoveMainAlerts.swift @@ -0,0 +1,270 @@ +import SwiftUI + +extension AppAlertState: TaggedAlertPresentable { + func alert(context: CoveMainPresentationContext) -> AnyAlertBuilder { + AlertBuilder( + title: title(), + message: { + AppAlertMessage(alert: self) + }, + actions: { + AppAlertActions(alert: self, context: context) + } + ).eraseToAny() + } +} + +private struct AppAlertMessage: View { + let alert: AppAlertState + + var body: some View { + let text = alert.message() + + if case .foundAddress = alert { + Text(text.map { "\($0)\u{200B}" }.joined()) + .font(.system(.caption2, design: .monospaced)) + .minimumScaleFactor(0.5) + .lineLimit(2) + } else { + Text(text) + } + } +} + +private struct AppAlertActions: View { + let alert: AppAlertState + let context: CoveMainPresentationContext + + private var app: AppManager { + context.app + } + + var body: some View { + switch alert { + case let .duplicateWallet(walletId: walletId): + Button("OK") { + app.alertState = .none + app.isSidebarVisible = false + try? app.selectWalletOrThrow(walletId) + } + + case let .hotWalletKeyMissing(walletId: walletId): + if CloudBackupManager.shared.isCloudBackupEnabled { + Button("Open Cloud Backup") { + app.alertState = .none + app.loadAndReset(to: .settings(.cloudBackup)) + } + } + + Button("Import 12 Words") { + app.alertState = .none + app.loadAndReset(to: .newWallet(.hotWallet(.import(.twelve, .manual)))) + } + + Button("Import 24 Words") { + app.alertState = .none + app.loadAndReset(to: .newWallet(.hotWallet(.import(.twentyFour, .manual)))) + } + + Button("Use with Hardware Wallet") { + do { + try app.ensureWalletManager(id: walletId).rust.setWalletType(walletType: .cold) + app.alertState = .none + } catch { + Log.error("Failed to set wallet type to cold: \(error)") + DispatchQueue.main.async { + app.alertState = .init( + .general( + title: "Error", + message: error.localizedDescription + ) + ) + } + } + } + + Button("Use as Watch Only", role: .cancel) { + DispatchQueue.main.async { app.alertState = .init(.confirmWatchOnly) } + } + + case .confirmWatchOnly: + Button("I Understand", role: .destructive) { + app.alertState = .none + } + + case let .addressWrongNetwork(address, _, _): + Button("Copy Address") { + UIPasteboard.general.string = String(address) + } + + Button("Cancel") { + app.alertState = .none + } + + case let .noWalletSelected(address): + Button("Copy Address") { + UIPasteboard.general.string = String(address) + } + + Button("Cancel") { + app.alertState = .none + } + + case let .foundAddress(address: address, amount: amount): + Button("Copy Address") { + UIPasteboard.general.string = String(address) + } + + if let id = Database().globalConfig().selectedWallet() { + Button("Send To Address") { + let route = RouteFactory().sendSetAmount( + id: id, + address: address, + amount: amount + ) + app.pushRoute(route) + app.alertState = .none + } + } + + Button("Cancel") { + app.alertState = .none + } + + case .noCameraPermission: + Button("OK") { + app.alertState = .none + let url = URL(string: UIApplication.openSettingsURLString)! + UIApplication.shared.open(url) + } + + case let .uninitializedTapSigner(tapSigner): + Button("Yes") { + app.isSidebarVisible = false + app.sheetState = .init(.tapSigner(TapSignerRoute.initSelect(tapSigner))) + } + + Button("Cancel", role: .cancel) { + app.alertState = .none + } + + case let .tapSignerWalletFound(walletId): + Button("Yes") { app.selectWallet(walletId) } + Button("Cancel", role: .cancel) { app.alertState = .none } + + case let .initializedTapSigner(tapSigner): + Button("Yes") { + app.sheetState = .init( + .tapSigner( + .enterPin(tapSigner: tapSigner, action: .derive) + ) + ) + } + Button("Cancel", role: .cancel) { app.alertState = .none } + + case let .tapSignerNoBackup(tapSigner): + Button("Yes") { + print("TODO: go to backup screen \(tapSigner)}") + } + Button("Cancel", role: .cancel) { app.alertState = .none } + + case let .tapSignerWrongPin(tapSigner, action): + Button("Try Again") { + app.sheetState = .init(.tapSigner(.enterPin(tapSigner: tapSigner, action: action))) + } + Button("Cancel", role: .cancel) { app.alertState = .none } + + case .cantSendOnWatchOnlyWallet: + Button("Import Hardware Wallet") { + DispatchQueue.main.async { app.alertState = .init(.watchOnlyImportHardware) } + } + Button("Import Words") { + DispatchQueue.main.async { app.alertState = .init(.watchOnlyImportWords) } + } + Button("Cancel", role: .cancel) { + app.alertState = .none + } + + case .watchOnlyImportHardware: + Button("QR Code") { + app.alertState = .none + app.pushRoute(.newWallet(.coldWallet(.qrCode))) + } + Button("NFC") { + app.alertState = .none + app.nfcReader.scan() + } + Button("Paste") { + app.alertState = .none + let text = UIPasteboard.general.string ?? "" + if text.isEmpty { return } + + do { + let wallet = try Wallet.newFromXpub(xpub: text) + try app.selectWalletOrThrow(wallet.id()) + app.resetRoute(to: .selectedWallet(wallet.id())) + } catch { + DispatchQueue.main.async { + app.alertState = .init( + .errorImportingHardwareWallet(message: error.localizedDescription) + ) + } + } + } + Button("Cancel", role: .cancel) { + app.alertState = .none + } + + case .watchOnlyImportWords: + Button("Scan QR") { + app.alertState = .none + app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .qr)))) + } + Button("NFC") { + app.alertState = .none + app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .nfc)))) + } + Button("12 Words") { + app.alertState = .none + app.pushRoute(.newWallet(.hotWallet(.import(.twelve, .manual)))) + } + Button("24 Words") { + app.alertState = .none + app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .manual)))) + } + Button("Cancel", role: .cancel) { + app.alertState = .none + } + + case let .walletDatabaseCorrupted(walletId, _): + Button("Delete Wallet", role: .destructive) { + app.alertState = .none + app.rust.deleteCorruptedWallet(id: walletId) + } + Button("Cancel", role: .cancel) { + app.alertState = .none + app.trySelectLatestOrNewWallet() + } + + case .invalidWordGroup, + .errorImportingHotWallet, + .importedSuccessfully, + .unableToSelectWallet, + .errorImportingHardwareWallet, + .invalidFileFormat, + .importedLabelsSuccessfully, + .unableToGetAddress, + .failedToScanQr, + .noUnsignedTransactionFound, + .tapSignerSetupFailed, + .tapSignerInvalidAuth, + .tapSignerDeriveFailed, + .general, + .invalidFormat, + .loading: + Button("OK") { + app.alertState = .none + } + } + } +} diff --git a/ios/Cove/CoveMainPresentationContext.swift b/ios/Cove/CoveMainPresentationContext.swift new file mode 100644 index 000000000..652db3465 --- /dev/null +++ b/ios/Cove/CoveMainPresentationContext.swift @@ -0,0 +1,6 @@ +import SwiftUI + +struct CoveMainPresentationContext { + let app: AppManager + let scannedCode: Binding?> +} diff --git a/ios/Cove/CoveMainSheets.swift b/ios/Cove/CoveMainSheets.swift new file mode 100644 index 000000000..fd8e9f65b --- /dev/null +++ b/ios/Cove/CoveMainSheets.swift @@ -0,0 +1,23 @@ +import SwiftUI + +extension AppSheetState: TaggedSheetPresentable { + func sheet(context: CoveMainPresentationContext) -> AnyView { + AnyView(AppSheetContent(sheet: self, context: context)) + } +} + +private struct AppSheetContent: View { + let sheet: AppSheetState + let context: CoveMainPresentationContext + + var body: some View { + switch sheet { + case .qr: + QrCodeScanView(app: context.app, scannedCode: context.scannedCode) + + case let .tapSigner(route): + TapSignerContainer(route: route) + .environment(context.app) + } + } +} diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index 9605ecdef..87bb8ef1e 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -19,241 +19,6 @@ struct CoveMainView: View { @State var scannedCode: TaggedItem? = .none @State var coverClearTask: Task? - @ViewBuilder - private func alertMessage(alert: TaggedItem) -> some View { - let text = alert.item.message() - - if case .foundAddress = alert.item { - Text(text.map { "\($0)\u{200B}" }.joined()) - .font(.system(.caption2, design: .monospaced)) - .minimumScaleFactor(0.5) - .lineLimit(2) - } else { - Text(text) - } - } - - @ViewBuilder - private func alertButtons(alert: TaggedItem) -> some View { - switch alert.item { - case let .duplicateWallet(walletId: walletId): - Button("OK") { - app.alertState = .none - app.isSidebarVisible = false - try? app.selectWalletOrThrow(walletId) - } - case let .hotWalletKeyMissing(walletId: walletId): - if CloudBackupManager.shared.isCloudBackupEnabled { - Button("Open Cloud Backup") { - app.alertState = .none - app.loadAndReset(to: .settings(.cloudBackup)) - } - } - - Button("Import 12 Words") { - app.alertState = .none - app.loadAndReset(to: .newWallet(.hotWallet(.import(.twelve, .manual)))) - } - - Button("Import 24 Words") { - app.alertState = .none - app.loadAndReset(to: .newWallet(.hotWallet(.import(.twentyFour, .manual)))) - } - - Button("Use with Hardware Wallet") { - do { - try app.ensureWalletManager(id: walletId).rust.setWalletType(walletType: .cold) - app.alertState = .none - } catch { - Log.error("Failed to set wallet type to cold: \(error)") - DispatchQueue.main.async { - app.alertState = .init( - .general( - title: "Error", - message: error.localizedDescription - ) - ) - } - } - } - - Button("Use as Watch Only", role: .cancel) { - DispatchQueue.main.async { app.alertState = .init(.confirmWatchOnly) } - } - case .confirmWatchOnly: - Button("I Understand", role: .destructive) { - app.alertState = .none - } - case let .addressWrongNetwork(address, _, _): - Button("Copy Address") { - UIPasteboard.general.string = String(address) - } - - Button("Cancel") { - app.alertState = .none - } - case let .noWalletSelected(address): - Button("Copy Address") { - UIPasteboard.general.string = String(address) - } - - Button("Cancel") { - app.alertState = .none - } - case let .foundAddress(address: address, amount: amount): - Button("Copy Address") { - UIPasteboard.general.string = String(address) - } - - if let id = Database().globalConfig().selectedWallet() { - Button("Send To Address") { - let route = RouteFactory().sendSetAmount( - id: id, address: address, amount: amount - ) - app.pushRoute(route) - app.alertState = .none - } - } - - Button("Cancel") { - app.alertState = .none - } - case .noCameraPermission: - Button("OK") { - app.alertState = .none - let url = URL(string: UIApplication.openSettingsURLString)! - UIApplication.shared.open(url) - } - case let .uninitializedTapSigner(tapSigner): - Button("Yes") { - app.isSidebarVisible = false - app.sheetState = .init(.tapSigner(TapSignerRoute.initSelect(tapSigner))) - } - - Button("Cancel", role: .cancel) { - app.alertState = .none - } - case let .tapSignerWalletFound(walletId): - Button("Yes") { app.selectWallet(walletId) } - Button("Cancel", role: .cancel) { app.alertState = .none } - case let .initializedTapSigner(tapSigner): - Button("Yes") { - app.sheetState = .init( - .tapSigner( - .enterPin(tapSigner: tapSigner, action: .derive) - ) - ) - } - Button("Cancel", role: .cancel) { app.alertState = .none } - case let .tapSignerNoBackup(tapSigner): - Button("Yes") { - print("TODO: go to backup screen \(tapSigner)}") - // TODO: go to backup screen - } - Button("Cancel", role: .cancel) { app.alertState = .none } - case let .tapSignerWrongPin(tapSigner, action): - Button("Try Again") { - app.sheetState = .init(.tapSigner(.enterPin(tapSigner: tapSigner, action: action))) - } - Button("Cancel", role: .cancel) { app.alertState = .none } - case .cantSendOnWatchOnlyWallet: - Button("Import Hardware Wallet") { - DispatchQueue.main.async { app.alertState = .init(.watchOnlyImportHardware) } - } - Button("Import Words") { - DispatchQueue.main.async { app.alertState = .init(.watchOnlyImportWords) } - } - Button("Cancel", role: .cancel) { - app.alertState = .none - } - case .watchOnlyImportHardware: - Button("QR Code") { - app.alertState = .none - app.pushRoute(.newWallet(.coldWallet(.qrCode))) - } - Button("NFC") { - app.alertState = .none - app.nfcReader.scan() - } - Button("Paste") { - app.alertState = .none - let text = UIPasteboard.general.string ?? "" - if text.isEmpty { return } - do { - let wallet = try Wallet.newFromXpub(xpub: text) - try app.selectWalletOrThrow(wallet.id()) - app.resetRoute(to: .selectedWallet(wallet.id())) - } catch { - DispatchQueue.main.async { - app.alertState = .init( - .errorImportingHardwareWallet(message: error.localizedDescription) - ) - } - } - } - Button("Cancel", role: .cancel) { - app.alertState = .none - } - case .watchOnlyImportWords: - Button("Scan QR") { - app.alertState = .none - app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .qr)))) - } - Button("NFC") { - app.alertState = .none - app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .nfc)))) - } - Button("12 Words") { - app.alertState = .none - app.pushRoute(.newWallet(.hotWallet(.import(.twelve, .manual)))) - } - Button("24 Words") { - app.alertState = .none - app.pushRoute(.newWallet(.hotWallet(.import(.twentyFour, .manual)))) - } - Button("Cancel", role: .cancel) { - app.alertState = .none - } - case let .walletDatabaseCorrupted(walletId, _): - Button("Delete Wallet", role: .destructive) { - app.alertState = .none - app.rust.deleteCorruptedWallet(id: walletId) - } - Button("Cancel", role: .cancel) { - app.alertState = .none - app.trySelectLatestOrNewWallet() - } - case .invalidWordGroup, - .errorImportingHotWallet, - .importedSuccessfully, - .unableToSelectWallet, - .errorImportingHardwareWallet, - .invalidFileFormat, - .importedLabelsSuccessfully, - .unableToGetAddress, - .failedToScanQr, - .noUnsignedTransactionFound, - .tapSignerSetupFailed, - .tapSignerInvalidAuth, - .tapSignerDeriveFailed, - .general, - .invalidFormat, - .loading: - Button("OK") { - app.alertState = .none - } - } - } - - private var showingAlert: Binding { - Binding( - get: { app.alertState != nil }, - set: { newValue in - if !newValue { app.alertState = .none } - } - ) - } - var navBarColor: Color { switch app.currentRoute { case .newWallet(.hotWallet(.create)): @@ -267,15 +32,8 @@ struct CoveMainView: View { } } - @ViewBuilder - func SheetContent(_ state: TaggedItem) -> some View { - switch state.item { - case .qr: - QrCodeScanView(app: app, scannedCode: $scannedCode) - case let .tapSigner(route): - TapSignerContainer(route: route) - .environment(app) - } + private var presentationContext: CoveMainPresentationContext { + CoveMainPresentationContext(app: app, scannedCode: $scannedCode) } var BodyView: some View { @@ -479,14 +237,8 @@ struct CoveMainView: View { .onChange(of: app.selectedNetwork) { id = UUID() } .onChange(of: scannedCode, onChangeQr) .onChange(of: app.nfcReader.scannedMessage, onChangeNfc) - .alert( - app.alertState?.item.title() ?? "Alert", - isPresented: showingAlert, - presenting: app.alertState, - actions: alertButtons, - message: alertMessage - ) - .sheet(item: $app.sheetState, content: SheetContent) + .presentingAlert($app.alertState, context: presentationContext) + .presentingSheet($app.sheetState, context: presentationContext) .onOpenURL(perform: ScanManager.shared.handleFileOpen) .onChange(of: phase, initial: true, handleScenePhaseChange) } diff --git a/ios/Cove/FFI/FileCoordinationClient.swift b/ios/Cove/FFI/FileCoordinationClient.swift new file mode 100644 index 000000000..582b5324b --- /dev/null +++ b/ios/Cove/FFI/FileCoordinationClient.swift @@ -0,0 +1,291 @@ +import CoveCore +import Foundation + +struct FileCoordinationClient { + static let legacyFileReadNoSuchFileError = 4 + + static func isConnectivityError(_ error: Error) -> Bool { + if let urlError = error as? URLError { + return [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotFindHost, + .cannotConnectToHost, + .dnsLookupFailed, + .internationalRoamingOff, + .dataNotAllowed, + ].contains(urlError.code) + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + return [ + NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorDataNotAllowed, + ].contains(nsError.code) + } + + return false + } + + static func uploadError(_ context: String, error: Error) -> CloudStorageError { + if isConnectivityError(error) { + return .Offline("\(context): \(error.localizedDescription)") + } + + return .UploadFailed("\(context): \(error.localizedDescription)") + } + + static func downloadError(_ context: String, error: Error) -> CloudStorageError { + if isConnectivityError(error) { + return .Offline("\(context): \(error.localizedDescription)") + } + + return .DownloadFailed("\(context): \(error.localizedDescription)") + } + + static func isNoSuchFileError(_ error: Error) -> Bool { + let nsError = error as NSError + guard nsError.domain == NSCocoaErrorDomain else { return false } + return nsError.code == NSFileNoSuchFileError + || nsError.code == NSFileReadNoSuchFileError + || nsError.code == Self.legacyFileReadNoSuchFileError + } + + static func isFileReadNoSuchFileError(_ error: Error) -> Bool { + let nsError = error as NSError + guard nsError.domain == NSCocoaErrorDomain else { return false } + return nsError.code == NSFileReadNoSuchFileError + || nsError.code == Self.legacyFileReadNoSuchFileError + } + + /// Coordinates iCloud-backed filesystem access because ubiquitous items may + /// be placeholders, may resolve to a different concrete URL, and can fail + /// if we touch them directly without asking the system to arbitrate first + func createDirectory(at url: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { return } + + var coordinatorError: NSError? + var createError: Error? + + let coordinator = NSFileCoordinator() + coordinator.coordinate(writingItemAt: url, options: [], error: &coordinatorError) { + newURL in + do { + try FileManager.default.createDirectory( + at: newURL, + withIntermediateDirectories: true + ) + } catch { + createError = error + } + } + + if let error = coordinatorError ?? createError { + throw Self.uploadError("create directory failed", error: error) + } + } + + func write(data: Data, to url: URL) throws { + var coordinatorError: NSError? + var writeError: Error? + + let coordinator = NSFileCoordinator() + coordinator.coordinate( + writingItemAt: url, options: .forReplacing, error: &coordinatorError + ) { newURL in + do { + try data.write(to: newURL, options: .atomic) + } catch { + writeError = error + } + } + + if let error = coordinatorError ?? writeError { + throw Self.uploadError("write failed", error: error) + } + } + + func writeForUpload(data: Data, to url: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { + try write(data: data, to: url) + return + } + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "icloud-upload-\(UUID().uuidString)-\(url.lastPathComponent)" + ) + + do { + try data.write(to: tempURL, options: .atomic) + } catch { + throw CloudStorageError.UploadFailed( + "temporary write failed: \(error.localizedDescription)" + ) + } + + defer { + if FileManager.default.fileExists(atPath: tempURL.path) { + try? FileManager.default.removeItem(at: tempURL) + } + } + + Log.info( + "writeForUpload: staging first upload via setUbiquitous for \(url.lastPathComponent)" + ) + + var coordinatorError: NSError? + var moveError: Error? + + let coordinator = NSFileCoordinator() + coordinator.coordinate(writingItemAt: url, options: [], error: &coordinatorError) { + destinationURL in + do { + try FileManager.default.setUbiquitous( + true, + itemAt: tempURL, + destinationURL: destinationURL + ) + } catch { + moveError = error + } + } + + if let error = coordinatorError ?? moveError { + throw Self.uploadError("setUbiquitous failed", error: error) + } + } + + func delete(at url: URL, missingItemID: String) throws { + var coordinatorError: NSError? + var deleteError: Error? + + let coordinator = NSFileCoordinator() + coordinator.coordinate( + writingItemAt: url, options: .forDeleting, error: &coordinatorError + ) { newURL in + do { + try FileManager.default.removeItem(at: newURL) + } catch { + deleteError = error + } + } + + if let error = coordinatorError ?? deleteError { + if Self.isNoSuchFileError(error) { throw CloudStorageError.NotFound(missingItemID) } + throw Self.uploadError("delete failed", error: error) + } + } + + func read(from url: URL) throws -> Data { + var coordinatorError: NSError? + var readResult: Result? + + let coordinator = NSFileCoordinator() + coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { + newURL in + do { + readResult = try .success(Data(contentsOf: newURL)) + } catch { + readResult = .failure(error) + } + } + + if let error = coordinatorError { + throw Self.downloadError("file coordination error", error: error) + } + + guard let readResult else { + throw CloudStorageError.DownloadFailed("coordinated read produced no result") + } + + switch readResult { + case let .success(data): return data + case let .failure(error): + throw Self.downloadError("read failed", error: error) + } + } + + func listSubdirectoriesViaFileManager(parentURL: URL) throws -> [String] { + let contents = try FileManager.default.contentsOfDirectory( + at: parentURL, includingPropertiesForKeys: [.isDirectoryKey], + options: [] + ) + + var names = Set() + for url in contents { + var name = url.lastPathComponent + + // iCloud evicted entries appear as .Name.icloud + if name.hasPrefix("."), name.hasSuffix(".icloud") { + name = String(name.dropFirst().dropLast(".icloud".count)) + names.insert(name) + continue + } + + if url.hasDirectoryPath { + names.insert(name) + } + } + + return Array(names) + } + + func listFilesViaFileManager(dirURL: URL, prefix: String) throws -> [String] { + let contents = try FileManager.default.contentsOfDirectory( + at: dirURL, includingPropertiesForKeys: nil, + options: [] + ) + + var names = Set() + for url in contents { + var name = url.lastPathComponent + + // iCloud evicted files appear as .FileName.icloud + if name.hasPrefix("."), name.hasSuffix(".icloud") { + name = String(name.dropFirst().dropLast(".icloud".count)) + } + + if name.hasPrefix(prefix) { + names.insert(name) + } + } + + return Array(names) + } + + func uploadDiagnostics(for url: URL) -> String { + let exists = FileManager.default.fileExists(atPath: url.path) + let fileSize: String = + if exists, + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attributes[.size] as? NSNumber { + size.stringValue + } else { + "nil" + } + + guard + let values = try? url.resourceValues(forKeys: [ + .isUbiquitousItemKey, + .ubiquitousItemIsUploadingKey, + .ubiquitousItemIsUploadedKey, + .ubiquitousItemUploadingErrorKey, + ]) + else { + return "exists=\(exists) fileSize=\(fileSize) values=" + } + + let errorDescription = values.ubiquitousItemUploadingError?.localizedDescription ?? "nil" + + return + "exists=\(exists) fileSize=\(fileSize) isUbiquitous=\(String(describing: values.isUbiquitousItem)) isUploading=\(String(describing: values.ubiquitousItemIsUploading)) isUploaded=\(String(describing: values.ubiquitousItemIsUploaded)) uploadingError=\(errorDescription)" + } +} diff --git a/ios/Cove/FFI/ICloudDriveHelper.swift b/ios/Cove/FFI/ICloudDriveHelper.swift index 0849e790e..be0496bc1 100644 --- a/ios/Cove/FFI/ICloudDriveHelper.swift +++ b/ios/Cove/FFI/ICloudDriveHelper.swift @@ -1,18 +1,15 @@ import CoveCore import Foundation -final class ICloudDriveHelper: @unchecked Sendable { +final class ICloudDriveHelper: Sendable { static let shared = ICloudDriveHelper() private let containerIdentifier = "iCloud.com.covebitcoinwallet" - private let dataSubdirectory = "Data" - private let namespacesSubdirectory = csppNamespacesSubdirectory() - private let walletsSubdirectory = csppWalletsDirectory() let defaultTimeout: TimeInterval = 60 - private let pollInterval: TimeInterval = 0.1 let metadataSettleInterval: TimeInterval = 0.5 - private let progressLogInterval: TimeInterval = 1 - private static let legacyFileReadNoSuchFileError = 4 + + private let fileCoordinationClient = FileCoordinationClient() + private let stateMachine = UploadDownloadStateMachine() final class ObserverBox { private var observers: [NSObjectProtocol] = [] @@ -29,49 +26,6 @@ final class ICloudDriveHelper: @unchecked Sendable { } } - struct ResolvedMetadataItem { - let url: URL - let metadataPath: String? - } - - private enum UploadState: CustomStringConvertible { - case uploaded - case uploading - case failed(Error) - case notUbiquitous - case unknown - - var description: String { - switch self { - case .uploaded: "uploaded" - case .uploading: "uploading" - case let .failed(error): "failed: \(error.localizedDescription)" - case .notUbiquitous: "not ubiquitous" - case .unknown: "unknown" - } - } - } - - private enum DownloadState: CustomStringConvertible { - case current - case downloading - case failed(Error) - case notUbiquitous - case notDownloaded - case unknown - - var description: String { - switch self { - case .current: "current" - case .downloading: "downloading" - case let .failed(error): "failed: \(error.localizedDescription)" - case .notUbiquitous: "not ubiquitous" - case .notDownloaded: "not downloaded" - case .unknown: "unknown" - } - } - } - enum MetadataLookupError: LocalizedError { case startFailed(String) case timedOut(String) @@ -87,53 +41,6 @@ final class ICloudDriveHelper: @unchecked Sendable { } } - private static func isConnectivityError(_ error: Error) -> Bool { - if let urlError = error as? URLError { - return [ - .notConnectedToInternet, - .networkConnectionLost, - .timedOut, - .cannotFindHost, - .cannotConnectToHost, - .dnsLookupFailed, - .internationalRoamingOff, - .dataNotAllowed, - ].contains(urlError.code) - } - - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain { - return [ - NSURLErrorNotConnectedToInternet, - NSURLErrorNetworkConnectionLost, - NSURLErrorTimedOut, - NSURLErrorCannotFindHost, - NSURLErrorCannotConnectToHost, - NSURLErrorDNSLookupFailed, - NSURLErrorInternationalRoamingOff, - NSURLErrorDataNotAllowed, - ].contains(nsError.code) - } - - return false - } - - private static func uploadError(_ context: String, error: Error) -> CloudStorageError { - if isConnectivityError(error) { - return .Offline("\(context): \(error.localizedDescription)") - } - - return .UploadFailed("\(context): \(error.localizedDescription)") - } - - private static func downloadError(_ context: String, error: Error) -> CloudStorageError { - if isConnectivityError(error) { - return .Offline("\(context): \(error.localizedDescription)") - } - - return .DownloadFailed("\(context): \(error.localizedDescription)") - } - // MARK: - Path mapping func containerURL() throws -> URL { @@ -148,68 +55,59 @@ final class ICloudDriveHelper: @unchecked Sendable { } func dataDirectoryURL() throws -> URL { - let url = try containerURL().appendingPathComponent(dataSubdirectory, isDirectory: true) - try coordinatedCreateDirectory(at: url) + let url = try paths().dataDirectoryURL() + try fileCoordinationClient.createDirectory(at: url) return url } func dataDirectoryReadURL() throws -> URL { - try containerURL().appendingPathComponent(dataSubdirectory, isDirectory: true) + try paths().dataDirectoryURL() } - /// Root directory for all namespaces: Data/cspp-namespaces/ func namespacesRootURL() throws -> URL { - let url = try dataDirectoryURL() - .appendingPathComponent(namespacesSubdirectory, isDirectory: true) - try coordinatedCreateDirectory(at: url) + _ = try dataDirectoryURL() + let url = try paths().namespacesRootURL() + try fileCoordinationClient.createDirectory(at: url) return url } func namespacesRootReadURL() throws -> URL { - try dataDirectoryReadURL() - .appendingPathComponent(namespacesSubdirectory, isDirectory: true) + try paths().namespacesRootURL() } func validateNamespace(_ namespace: String) throws -> String { - guard Self.isValidNamespaceID(namespace) else { - throw CloudStorageError.InvalidNamespace("expected 32 lowercase hex characters") - } - - return namespace + try ICloudPaths.validateNamespace(namespace) } - /// Directory for a specific namespace: Data/cspp-namespaces/{namespace}/ func namespaceDirectoryURL(namespace: String) throws -> URL { let namespace = try validateNamespace(namespace) let url = try namespacesRootURL() .appendingPathComponent(namespace, isDirectory: true) - try coordinatedCreateDirectory(at: url) + try fileCoordinationClient.createDirectory(at: url) return url } func namespaceDirectoryReadURL(namespace: String) throws -> URL { - let namespace = try validateNamespace(namespace) - return try namespacesRootReadURL() - .appendingPathComponent(namespace, isDirectory: true) + try paths().namespaceDirectoryURL(namespace: namespace) } func walletsDirectoryReadURL(namespace: String) throws -> URL { - try namespaceDirectoryReadURL(namespace: namespace) - .appendingPathComponent(walletsSubdirectory, isDirectory: true) + try paths().walletsDirectoryURL(namespace: namespace) } func walletLocation(filename: String) -> String { - "\(walletsSubdirectory)/\(filename)" + ICloudPaths.walletLocation(filename: filename) } func backupFileURL(namespace: String, location: RemoteBackupLocation) throws -> URL { - let namespaceDirectory = try namespaceDirectoryURL(namespace: namespace) - return try appendBackupLocation(location, to: namespaceDirectory, createParentDirectories: true) + _ = try namespaceDirectoryURL(namespace: namespace) + let url = try paths().backupFileURL(namespace: namespace, location: location) + try fileCoordinationClient.createDirectory(at: url.deletingLastPathComponent()) + return url } func backupFileReadURL(namespace: String, location: RemoteBackupLocation) throws -> URL { - let namespaceDirectory = try namespaceDirectoryReadURL(namespace: namespace) - return try appendBackupLocation(location, to: namespaceDirectory, createParentDirectories: false) + try paths().backupFileURL(namespace: namespace, location: location) } func existingBackupFileReadURL( @@ -239,178 +137,26 @@ final class ICloudDriveHelper: @unchecked Sendable { throw CloudStorageError.NotFound(recordId) } - private func appendBackupLocation( - _ location: RemoteBackupLocation, - to namespaceDirectory: URL, - createParentDirectories: Bool - ) throws -> URL { - let parts = location.relativePath.split(separator: "/").map(String.init) - guard - !parts.isEmpty, - parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) - else { - throw CloudStorageError.NotAvailable("invalid backup location") - } - - var directory = namespaceDirectory - for folder in parts.dropLast() { - directory.appendPathComponent(folder, isDirectory: true) - } - - if createParentDirectories { - try coordinatedCreateDirectory(at: directory) - } - - return directory.appendingPathComponent(parts.last!) + private func paths() throws -> ICloudPaths { + try ICloudPaths(containerURL: containerURL()) } // MARK: - File coordination - /// Coordinates iCloud-backed filesystem access because ubiquitous items may - /// be placeholders, may resolve to a different concrete URL, and can fail - /// if we touch them directly without asking the system to arbitrate first - private func coordinatedCreateDirectory(at url: URL) throws { - guard !FileManager.default.fileExists(atPath: url.path) else { return } - - var coordinatorError: NSError? - var createError: Error? - - let coordinator = NSFileCoordinator() - coordinator.coordinate(writingItemAt: url, options: [], error: &coordinatorError) { - newURL in - do { - try FileManager.default.createDirectory( - at: newURL, - withIntermediateDirectories: true - ) - } catch { - createError = error - } - } - - if let error = coordinatorError ?? createError { - throw Self.uploadError("create directory failed", error: error) - } - } - func coordinatedWrite(data: Data, to url: URL) throws { - var coordinatorError: NSError? - var writeError: Error? - - let coordinator = NSFileCoordinator() - coordinator.coordinate( - writingItemAt: url, options: .forReplacing, error: &coordinatorError - ) { newURL in - do { - try data.write(to: newURL, options: .atomic) - } catch { - writeError = error - } - } - - if let error = coordinatorError ?? writeError { - throw Self.uploadError("write failed", error: error) - } + try fileCoordinationClient.write(data: data, to: url) } func writeForUpload(data: Data, to url: URL) throws { - guard !FileManager.default.fileExists(atPath: url.path) else { - try coordinatedWrite(data: data, to: url) - return - } - - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent( - "icloud-upload-\(UUID().uuidString)-\(url.lastPathComponent)" - ) - - do { - try data.write(to: tempURL, options: .atomic) - } catch { - throw CloudStorageError.UploadFailed( - "temporary write failed: \(error.localizedDescription)" - ) - } - - defer { - if FileManager.default.fileExists(atPath: tempURL.path) { - try? FileManager.default.removeItem(at: tempURL) - } - } - - Log.info( - "writeForUpload: staging first upload via setUbiquitous for \(url.lastPathComponent)" - ) - - var coordinatorError: NSError? - var moveError: Error? - - let coordinator = NSFileCoordinator() - coordinator.coordinate(writingItemAt: url, options: [], error: &coordinatorError) { - destinationURL in - do { - try FileManager.default.setUbiquitous( - true, - itemAt: tempURL, - destinationURL: destinationURL - ) - } catch { - moveError = error - } - } - - if let error = coordinatorError ?? moveError { - throw Self.uploadError("setUbiquitous failed", error: error) - } + try fileCoordinationClient.writeForUpload(data: data, to: url) } func coordinatedDelete(at url: URL, missingItemID: String) throws { - var coordinatorError: NSError? - var deleteError: Error? - - let coordinator = NSFileCoordinator() - coordinator.coordinate( - writingItemAt: url, options: .forDeleting, error: &coordinatorError - ) { newURL in - do { - try FileManager.default.removeItem(at: newURL) - } catch { - deleteError = error - } - } - - if let error = coordinatorError ?? deleteError { - if Self.isNoSuchFileError(error) { throw CloudStorageError.NotFound(missingItemID) } - throw Self.uploadError("delete failed", error: error) - } + try fileCoordinationClient.delete(at: url, missingItemID: missingItemID) } func coordinatedRead(from url: URL) throws -> Data { - var coordinatorError: NSError? - var readResult: Result? - - let coordinator = NSFileCoordinator() - coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { - newURL in - do { - readResult = try .success(Data(contentsOf: newURL)) - } catch { - readResult = .failure(error) - } - } - - if let error = coordinatorError { - throw Self.downloadError("file coordination error", error: error) - } - - guard let readResult else { - throw CloudStorageError.DownloadFailed("coordinated read produced no result") - } - - switch readResult { - case let .success(data): return data - case let .failure(error): - throw Self.downloadError("read failed", error: error) - } + try fileCoordinationClient.read(from: url) } /// Downloads a file from iCloud via coordinated read @@ -418,235 +164,80 @@ final class ICloudDriveHelper: @unchecked Sendable { /// Tries startDownloadingUbiquitousItem as a hint, then uses NSFileCoordinator /// which forces the download through a different (more reliable) path func downloadFile(url: URL, recordId: String) throws -> Data { - try downloadData(url: url, recordId: recordId) + try stateMachine.downloadFile( + url: url, + recordId: recordId, + fileExists: { FileManager.default.fileExists(atPath: $0.path) }, + downloadState: { self.downloadState(for: $0) }, + waitForMetadataItem: { name, parentDirectoryURL, deadline in + try self.waitForMetadataItem( + named: name, + parentDirectoryURL: parentDirectoryURL, + deadline: deadline + ) + }, + triggerDownload: { url, recordId, filename in + try self.triggerDownload(url: url, recordId: recordId, filename: filename) + }, + coordinatedRead: { try self.coordinatedRead(from: $0) } + ) } // MARK: - Upload verification - /// Blocks until the file is visible through iCloud metadata func waitForMetadataVisibility(url: URL) throws { - let filename = url.lastPathComponent - let deadline = Date().addingTimeInterval(defaultTimeout) - - do { - let resolvedItem = try waitForMetadataItem( - named: filename, - parentDirectoryURL: url.deletingLastPathComponent(), - deadline: deadline - ) - if resolvedItem.url != url { - Log.info( - "waitForMetadataVisibility: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" + try stateMachine.waitForMetadataVisibility( + url: url, + waitForMetadataItem: { name, parentDirectoryURL, deadline in + try self.waitForMetadataItem( + named: name, + parentDirectoryURL: parentDirectoryURL, + deadline: deadline ) } - } catch { - throw Self.uploadError("iCloud metadata lookup failed for \(filename)", error: error) - } + ) } - /// Blocks until the file at `url` is confirmed uploaded to iCloud, or times out func waitForUpload(url: URL) throws { - let filename = url.lastPathComponent - Log.info("waitForUpload: waiting for \(filename)") - let deadline = Date().addingTimeInterval(defaultTimeout) - - if case .uploaded = uploadState(for: url) { - Log.info("waitForUpload: \(filename) already uploaded on local URL") - return - } - - let resolvedItem: ResolvedMetadataItem - do { - resolvedItem = try waitForMetadataItem( - named: filename, - parentDirectoryURL: url.deletingLastPathComponent(), - deadline: deadline - ) - } catch { - throw Self.uploadError("iCloud metadata lookup failed for \(filename)", error: error) - } - - if resolvedItem.url != url { - Log.info( - "waitForUpload: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" - ) - } - - var lastProgressLog = Date.distantPast - - while Date() < deadline { - let state = uploadState(for: resolvedItem.url) - let now = Date() - - if now.timeIntervalSince(lastProgressLog) >= progressLogInterval { - Log.info( - "waitForUpload: \(filename) state=\(state) metadataPath=\(resolvedItem.metadataPath ?? "") diagnostics=\(uploadDiagnostics(for: resolvedItem.url))" + try stateMachine.waitForUpload( + url: url, + waitForMetadataItem: { name, parentDirectoryURL, deadline in + try self.waitForMetadataItem( + named: name, + parentDirectoryURL: parentDirectoryURL, + deadline: deadline + ) + }, + uploadState: { self.uploadState(for: $0) }, + uploadDiagnostics: { self.fileCoordinationClient.uploadDiagnostics(for: $0) }, + logMetadataItems: { parentDirectoryURL, reason, focusName in + self.logMetadataItems( + under: parentDirectoryURL, + reason: reason, + focusName: focusName ) - lastProgressLog = now - } - - if case .uploaded = state { - Log.info("waitForUpload: \(filename) uploaded") - return - } - - if case let .failed(error) = state { - throw Self.uploadError("iCloud upload failed for \(filename)", error: error) } - - Thread.sleep(forTimeInterval: pollInterval) - } - - Log.info( - "waitForUpload: timeout diagnostics \(filename) metadataPath=\(resolvedItem.metadataPath ?? "") diagnostics=\(uploadDiagnostics(for: resolvedItem.url))" - ) - logMetadataItems( - under: url.deletingLastPathComponent(), - reason: "waitForUpload timeout", - focusName: filename - ) - - throw CloudStorageError.Offline( - "iCloud upload timed out for \(filename) after \(defaultTimeout)s" ) } // MARK: - Download - /// Ensures the file is downloaded locally, triggering a download if evicted func ensureDownloaded(url: URL, recordId: String) throws { - _ = try downloadData(url: url, recordId: recordId) - } - - private func downloadData(url: URL, recordId: String) throws -> Data { - let filename = url.lastPathComponent - - if FileManager.default.fileExists(atPath: url.path), case .current = downloadState(for: url) { - Log.info("downloadFile: \(filename) already current on local URL") - return try coordinatedRead(from: url) - } - - let deadline = Date().addingTimeInterval(defaultTimeout) - - let resolvedItem: ResolvedMetadataItem - do { - resolvedItem = try waitForMetadataItem( - named: filename, - parentDirectoryURL: url.deletingLastPathComponent(), - deadline: deadline - ) - } catch { - throw Self.downloadError("iCloud metadata lookup failed for \(filename)", error: error) - } - - if resolvedItem.url != url { - Log.info( - "downloadFile: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" - ) - } else { - Log.info("downloadFile: \(filename) reading via resolved local URL") - } - - try triggerDownload(url: resolvedItem.url, recordId: recordId, filename: filename) - - var coordinatedReadAttempt = 1 - var lastCoordinatedReadError: Error? - - // try a coordinated read before waiting for iCloud status to catch up - Log.info( - "downloadFile: trying coordinated read attempt=\(coordinatedReadAttempt) reason=initial file=\(filename)" - ) - - do { - let data = try coordinatedRead(from: resolvedItem.url) - Log.info("downloadFile: coordinated read succeeded for \(filename)") - return data - } catch { - lastCoordinatedReadError = error - Log.warn( - "downloadFile: coordinated read failed attempt=\(coordinatedReadAttempt): \(error.localizedDescription)" - ) - } - - // poll with periodic re-triggers and inline coordinated reads because - // some restored-device placeholders never transition out of - // not-downloaded even though they can be materialized - let retriggerInterval: TimeInterval = 5 - var lastRetrigger = Date() - var lastProgressLog = Date.distantPast - - while Date() < deadline { - let now = Date() - let state = downloadState(for: resolvedItem.url) - - if now.timeIntervalSince(lastRetrigger) >= retriggerInterval { - try? triggerDownload(url: resolvedItem.url, recordId: recordId, filename: filename) - lastRetrigger = now - coordinatedReadAttempt += 1 - - Log.info( - "downloadFile: trying coordinated read attempt=\(coordinatedReadAttempt) reason=retry file=\(filename)" - ) - - do { - let data = try coordinatedRead(from: resolvedItem.url) - Log.info("downloadFile: coordinated read succeeded for \(filename)") - return data - } catch { - lastCoordinatedReadError = error - Log.warn( - "downloadFile: coordinated read failed attempt=\(coordinatedReadAttempt): \(error.localizedDescription)" - ) - } - } - - if now.timeIntervalSince(lastProgressLog) >= progressLogInterval { - Log.info( - "downloadFile: \(filename) state=\(state) metadataPath=\(resolvedItem.metadataPath ?? "")" - ) - lastProgressLog = now - } - - if case .current = state { - Log.info("downloadFile: poll path won for \(filename)") - return try coordinatedRead(from: resolvedItem.url) - } - - if case let .failed(error) = state { - throw Self.downloadError("iCloud download failed", error: error) - } - - Thread.sleep(forTimeInterval: pollInterval) - } - - Log.info("downloadFile: polling timed out, trying final coordinated read for \(filename)") - do { - return try coordinatedRead(from: resolvedItem.url) - } catch { - let diagnosticError = lastCoordinatedReadError?.localizedDescription ?? "none" - throw CloudStorageError.Offline( - "iCloud download timed out after \(defaultTimeout)s (last coordinated read error: \(diagnosticError), final coordinated read failed: \(error.localizedDescription))" - ) - } + _ = try downloadFile(url: url, recordId: recordId) } private func triggerDownload(url: URL, recordId: String, filename _: String) throws { do { try FileManager.default.startDownloadingUbiquitousItem(at: url) } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain, - nsError.code == NSFileReadNoSuchFileError - // some iCloud missing-file cases surface as legacy Cocoa code 4 - || nsError.code == Self.legacyFileReadNoSuchFileError - { + if FileCoordinationClient.isFileReadNoSuchFileError(error) { throw CloudStorageError.NotFound(recordId) } Log.warn("downloadFile: startDownloading failed: \(error.localizedDescription)") } } - private func uploadState(for url: URL) -> UploadState { + private func uploadState(for url: URL) -> UploadDownloadStateMachine.UploadState { // clear cached resource values to prevent stale reads var freshURL = url freshURL.removeAllCachedResourceValues() @@ -669,35 +260,7 @@ final class ICloudDriveHelper: @unchecked Sendable { return .uploading } - private func uploadDiagnostics(for url: URL) -> String { - let exists = FileManager.default.fileExists(atPath: url.path) - let fileSize: String = - if exists, - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attributes[.size] as? NSNumber { - size.stringValue - } else { - "nil" - } - - guard - let values = try? url.resourceValues(forKeys: [ - .isUbiquitousItemKey, - .ubiquitousItemIsUploadingKey, - .ubiquitousItemIsUploadedKey, - .ubiquitousItemUploadingErrorKey, - ]) - else { - return "exists=\(exists) fileSize=\(fileSize) values=" - } - - let errorDescription = values.ubiquitousItemUploadingError?.localizedDescription ?? "nil" - - return - "exists=\(exists) fileSize=\(fileSize) isUbiquitous=\(String(describing: values.isUbiquitousItem)) isUploading=\(String(describing: values.ubiquitousItemIsUploading)) isUploaded=\(String(describing: values.ubiquitousItemIsUploaded)) uploadingError=\(errorDescription)" - } - - private func downloadState(for url: URL) -> DownloadState { + private func downloadState(for url: URL) -> UploadDownloadStateMachine.DownloadState { var freshURL = url freshURL.removeAllCachedResourceValues() @@ -727,7 +290,9 @@ final class ICloudDriveHelper: @unchecked Sendable { func listSubdirectories(parentPath: String) throws -> [String] { let parentURL = URL(fileURLWithPath: parentPath, isDirectory: true) - if let names = try? listSubdirectoriesViaFileManager(parentURL: parentURL), !names.isEmpty { + if let names = try? fileCoordinationClient.listSubdirectoriesViaFileManager( + parentURL: parentURL + ), !names.isEmpty { return names.sorted() } @@ -735,31 +300,6 @@ final class ICloudDriveHelper: @unchecked Sendable { return try metadataSubdirectoryNames(parentDirectoryURL: parentURL) } - private func listSubdirectoriesViaFileManager(parentURL: URL) throws -> [String] { - let contents = try FileManager.default.contentsOfDirectory( - at: parentURL, includingPropertiesForKeys: [.isDirectoryKey], - options: [] - ) - - var names = Set() - for url in contents { - var name = url.lastPathComponent - - // iCloud evicted entries appear as .Name.icloud - if name.hasPrefix("."), name.hasSuffix(".icloud") { - name = String(name.dropFirst().dropLast(".icloud".count)) - names.insert(name) - continue - } - - if url.hasDirectoryPath { - names.insert(name) - } - } - - return Array(names) - } - /// Lists filenames matching a prefix within a namespace directory /// /// Tries FileManager first (fast, sees .icloud stubs and dataless files), @@ -767,7 +307,10 @@ final class ICloudDriveHelper: @unchecked Sendable { func listFiles(namespacePath: String, prefix: String) throws -> [String] { let dirURL = URL(fileURLWithPath: namespacePath, isDirectory: true) - if let names = try? listFilesViaFileManager(dirURL: dirURL, prefix: prefix), !names.isEmpty { + if let names = try? fileCoordinationClient.listFilesViaFileManager( + dirURL: dirURL, + prefix: prefix + ), !names.isEmpty { return names.sorted() } @@ -775,29 +318,6 @@ final class ICloudDriveHelper: @unchecked Sendable { return try metadataFileNames(parentDirectoryURL: dirURL, prefix: prefix) } - private func listFilesViaFileManager(dirURL: URL, prefix: String) throws -> [String] { - let contents = try FileManager.default.contentsOfDirectory( - at: dirURL, includingPropertiesForKeys: nil, - options: [] - ) - - var names = Set() - for url in contents { - var name = url.lastPathComponent - - // iCloud evicted files appear as .FileName.icloud - if name.hasPrefix("."), name.hasSuffix(".icloud") { - name = String(name.dropFirst().dropLast(".icloud".count)) - } - - if name.hasPrefix(prefix) { - names.insert(name) - } - } - - return Array(names) - } - // MARK: - Upload status for UI enum UploadStatus { @@ -913,27 +433,8 @@ final class ICloudDriveHelper: @unchecked Sendable { } } - private static func isNoSuchFileError(_ error: Error) -> Bool { - let nsError = error as NSError - guard nsError.domain == NSCocoaErrorDomain else { return false } - return nsError.code == NSFileNoSuchFileError - || nsError.code == NSFileReadNoSuchFileError - || nsError.code == Self.legacyFileReadNoSuchFileError - } - - private static func isValidNamespaceID(_ namespace: String) -> Bool { - namespace.count == 32 && namespace.utf8.allSatisfy { byte in - switch byte { - case 48 ... 57, 97 ... 102: - true - default: - false - } - } - } - static func isValidNamespaceDirectory(_ url: URL) -> Bool { - url.hasDirectoryPath && isValidNamespaceID(url.lastPathComponent) + ICloudPaths.isValidNamespaceDirectory(url) } /// Checks sync health of all files in namespace directories diff --git a/ios/Cove/FFI/ICloudPaths.swift b/ios/Cove/FFI/ICloudPaths.swift new file mode 100644 index 000000000..5d171bd7e --- /dev/null +++ b/ios/Cove/FFI/ICloudPaths.swift @@ -0,0 +1,108 @@ +import CoveCore +import Foundation + +struct ICloudPaths { + private static let defaultDataSubdirectory = "Data" + private static let defaultNamespacesSubdirectory = csppNamespacesSubdirectory() + private static let defaultWalletsSubdirectory = csppWalletsDirectory() + + let containerURL: URL + + private let dataSubdirectory: String + private let namespacesSubdirectory: String + private let walletsSubdirectory: String + + init( + containerURL: URL, + dataSubdirectory: String = Self.defaultDataSubdirectory, + namespacesSubdirectory: String = Self.defaultNamespacesSubdirectory, + walletsSubdirectory: String = Self.defaultWalletsSubdirectory + ) { + self.containerURL = containerURL + self.dataSubdirectory = dataSubdirectory + self.namespacesSubdirectory = namespacesSubdirectory + self.walletsSubdirectory = walletsSubdirectory + } + + func dataDirectoryURL() -> URL { + containerURL.appendingPathComponent(dataSubdirectory, isDirectory: true) + } + + /// Root directory for all namespaces: Data/cspp-namespaces/ + func namespacesRootURL() -> URL { + dataDirectoryURL() + .appendingPathComponent(namespacesSubdirectory, isDirectory: true) + } + + func validateNamespace(_ namespace: String) throws -> String { + try Self.validateNamespace(namespace) + } + + /// Directory for a specific namespace: Data/cspp-namespaces/{namespace}/ + func namespaceDirectoryURL(namespace: String) throws -> URL { + let namespace = try validateNamespace(namespace) + return namespacesRootURL() + .appendingPathComponent(namespace, isDirectory: true) + } + + func walletsDirectoryURL(namespace: String) throws -> URL { + try namespaceDirectoryURL(namespace: namespace) + .appendingPathComponent(walletsSubdirectory, isDirectory: true) + } + + func walletLocation(filename: String) -> String { + "\(walletsSubdirectory)/\(filename)" + } + + func backupFileURL(namespace: String, location: RemoteBackupLocation) throws -> URL { + let namespaceDirectory = try namespaceDirectoryURL(namespace: namespace) + return try appendBackupLocation(location, to: namespaceDirectory) + } + + static func isValidNamespaceDirectory(_ url: URL) -> Bool { + url.hasDirectoryPath && isValidNamespaceID(url.lastPathComponent) + } + + static func validateNamespace(_ namespace: String) throws -> String { + guard isValidNamespaceID(namespace) else { + throw CloudStorageError.InvalidNamespace("expected 32 lowercase hex characters") + } + + return namespace + } + + static func walletLocation(filename: String) -> String { + "\(defaultWalletsSubdirectory)/\(filename)" + } + + private func appendBackupLocation( + _ location: RemoteBackupLocation, + to namespaceDirectory: URL + ) throws -> URL { + let parts = location.relativePath.split(separator: "/").map(String.init) + guard + !parts.isEmpty, + parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) + else { + throw CloudStorageError.NotAvailable("invalid backup location") + } + + var directory = namespaceDirectory + for folder in parts.dropLast() { + directory.appendPathComponent(folder, isDirectory: true) + } + + return directory.appendingPathComponent(parts.last!) + } + + private static func isValidNamespaceID(_ namespace: String) -> Bool { + namespace.count == 32 && namespace.utf8.allSatisfy { byte in + switch byte { + case 48 ... 57, 97 ... 102: + true + default: + false + } + } + } +} diff --git a/ios/Cove/FFI/MakeIdentifiable.swift b/ios/Cove/FFI/MakeIdentifiable.swift deleted file mode 100644 index 3fde5b06e..000000000 --- a/ios/Cove/FFI/MakeIdentifiable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// MakeIdentifiable.swift -// Cove -// -// Created by Praveen Perera on 8/26/24. -// - -import Foundation - -extension WalletErrorAlert: Identifiable { - public var id: Self { - self - } -} diff --git a/ios/Cove/FFI/UploadDownloadStateMachine.swift b/ios/Cove/FFI/UploadDownloadStateMachine.swift new file mode 100644 index 000000000..5e081feda --- /dev/null +++ b/ios/Cove/FFI/UploadDownloadStateMachine.swift @@ -0,0 +1,303 @@ +import CoveCore +import Foundation + +struct ResolvedMetadataItem { + let url: URL + let metadataPath: String? +} + +struct UploadDownloadStateMachine { + enum UploadState: CustomStringConvertible { + case uploaded + case uploading + case failed(Error) + case notUbiquitous + case unknown + + var description: String { + switch self { + case .uploaded: "uploaded" + case .uploading: "uploading" + case let .failed(error): "failed: \(error.localizedDescription)" + case .notUbiquitous: "not ubiquitous" + case .unknown: "unknown" + } + } + } + + enum DownloadState: CustomStringConvertible { + case current + case downloading + case failed(Error) + case notUbiquitous + case notDownloaded + case unknown + + var description: String { + switch self { + case .current: "current" + case .downloading: "downloading" + case let .failed(error): "failed: \(error.localizedDescription)" + case .notUbiquitous: "not ubiquitous" + case .notDownloaded: "not downloaded" + case .unknown: "unknown" + } + } + } + + struct Clock { + let now: @Sendable () -> Date + let sleep: @Sendable (TimeInterval) -> Void + + static let system = Clock( + now: { Date() }, + sleep: { interval in Thread.sleep(forTimeInterval: interval) } + ) + } + + let defaultTimeout: TimeInterval + let pollInterval: TimeInterval + let progressLogInterval: TimeInterval + let clock: Clock + + init( + defaultTimeout: TimeInterval = 60, + pollInterval: TimeInterval = 0.1, + progressLogInterval: TimeInterval = 1, + clock: Clock = .system + ) { + self.defaultTimeout = defaultTimeout + self.pollInterval = pollInterval + self.progressLogInterval = progressLogInterval + self.clock = clock + } + + /// Blocks until the file is visible through iCloud metadata + func waitForMetadataVisibility( + url: URL, + waitForMetadataItem: (String, URL, Date) throws -> ResolvedMetadataItem + ) throws { + let filename = url.lastPathComponent + let deadline = clock.now().addingTimeInterval(defaultTimeout) + + do { + let resolvedItem = try waitForMetadataItem( + filename, + url.deletingLastPathComponent(), + deadline + ) + if resolvedItem.url != url { + Log.info( + "waitForMetadataVisibility: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" + ) + } + } catch { + throw FileCoordinationClient.uploadError( + "iCloud metadata lookup failed for \(filename)", + error: error + ) + } + } + + /// Blocks until the file at `url` is confirmed uploaded to iCloud, or times out + func waitForUpload( + url: URL, + waitForMetadataItem: (String, URL, Date) throws -> ResolvedMetadataItem, + uploadState: (URL) -> UploadState, + uploadDiagnostics: (URL) -> String, + logMetadataItems: (URL, String, String) -> Void + ) throws { + let filename = url.lastPathComponent + Log.info("waitForUpload: waiting for \(filename)") + let deadline = clock.now().addingTimeInterval(defaultTimeout) + + if case .uploaded = uploadState(url) { + Log.info("waitForUpload: \(filename) already uploaded on local URL") + return + } + + let resolvedItem: ResolvedMetadataItem + do { + resolvedItem = try waitForMetadataItem( + filename, + url.deletingLastPathComponent(), + deadline + ) + } catch { + throw FileCoordinationClient.uploadError( + "iCloud metadata lookup failed for \(filename)", + error: error + ) + } + + if resolvedItem.url != url { + Log.info( + "waitForUpload: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" + ) + } + + var lastProgressLog = Date.distantPast + + while clock.now() < deadline { + let state = uploadState(resolvedItem.url) + let now = clock.now() + + if now.timeIntervalSince(lastProgressLog) >= progressLogInterval { + Log.info( + "waitForUpload: \(filename) state=\(state) metadataPath=\(resolvedItem.metadataPath ?? "") diagnostics=\(uploadDiagnostics(resolvedItem.url))" + ) + lastProgressLog = now + } + + if case .uploaded = state { + Log.info("waitForUpload: \(filename) uploaded") + return + } + + if case let .failed(error) = state { + throw FileCoordinationClient.uploadError( + "iCloud upload failed for \(filename)", + error: error + ) + } + + clock.sleep(pollInterval) + } + + Log.info( + "waitForUpload: timeout diagnostics \(filename) metadataPath=\(resolvedItem.metadataPath ?? "") diagnostics=\(uploadDiagnostics(resolvedItem.url))" + ) + logMetadataItems( + url.deletingLastPathComponent(), + "waitForUpload timeout", + filename + ) + + throw CloudStorageError.Offline( + "iCloud upload timed out for \(filename) after \(defaultTimeout)s" + ) + } + + func downloadFile( + url: URL, + recordId: String, + fileExists: (URL) -> Bool, + downloadState: (URL) -> DownloadState, + waitForMetadataItem: (String, URL, Date) throws -> ResolvedMetadataItem, + triggerDownload: (URL, String, String) throws -> Void, + coordinatedRead: (URL) throws -> Data + ) throws -> Data { + let filename = url.lastPathComponent + + if fileExists(url), case .current = downloadState(url) { + Log.info("downloadFile: \(filename) already current on local URL") + return try coordinatedRead(url) + } + + let deadline = clock.now().addingTimeInterval(defaultTimeout) + + let resolvedItem: ResolvedMetadataItem + do { + resolvedItem = try waitForMetadataItem( + filename, + url.deletingLastPathComponent(), + deadline + ) + } catch { + throw FileCoordinationClient.downloadError( + "iCloud metadata lookup failed for \(filename)", + error: error + ) + } + + if resolvedItem.url != url { + Log.info( + "downloadFile: using metadata URL for \(filename) local=\(url.path) metadata=\(resolvedItem.url.path)" + ) + } else { + Log.info("downloadFile: \(filename) reading via resolved local URL") + } + + try triggerDownload(resolvedItem.url, recordId, filename) + + var coordinatedReadAttempt = 1 + var lastCoordinatedReadError: Error? + + // try a coordinated read before waiting for iCloud status to catch up + Log.info( + "downloadFile: trying coordinated read attempt=\(coordinatedReadAttempt) reason=initial file=\(filename)" + ) + + do { + let data = try coordinatedRead(resolvedItem.url) + Log.info("downloadFile: coordinated read succeeded for \(filename)") + return data + } catch { + lastCoordinatedReadError = error + Log.warn( + "downloadFile: coordinated read failed attempt=\(coordinatedReadAttempt): \(error.localizedDescription)" + ) + } + + // poll with periodic re-triggers and inline coordinated reads because + // some restored-device placeholders never transition out of + // not-downloaded even though they can be materialized + let retriggerInterval: TimeInterval = 5 + var lastRetrigger = clock.now() + var lastProgressLog = Date.distantPast + + while clock.now() < deadline { + let now = clock.now() + let state = downloadState(resolvedItem.url) + + if now.timeIntervalSince(lastRetrigger) >= retriggerInterval { + try? triggerDownload(resolvedItem.url, recordId, filename) + lastRetrigger = now + coordinatedReadAttempt += 1 + + Log.info( + "downloadFile: trying coordinated read attempt=\(coordinatedReadAttempt) reason=retry file=\(filename)" + ) + + do { + let data = try coordinatedRead(resolvedItem.url) + Log.info("downloadFile: coordinated read succeeded for \(filename)") + return data + } catch { + lastCoordinatedReadError = error + Log.warn( + "downloadFile: coordinated read failed attempt=\(coordinatedReadAttempt): \(error.localizedDescription)" + ) + } + } + + if now.timeIntervalSince(lastProgressLog) >= progressLogInterval { + Log.info( + "downloadFile: \(filename) state=\(state) metadataPath=\(resolvedItem.metadataPath ?? "")" + ) + lastProgressLog = now + } + + if case .current = state { + Log.info("downloadFile: poll path won for \(filename)") + return try coordinatedRead(resolvedItem.url) + } + + if case let .failed(error) = state { + throw FileCoordinationClient.downloadError("iCloud download failed", error: error) + } + + clock.sleep(pollInterval) + } + + Log.info("downloadFile: polling timed out, trying final coordinated read for \(filename)") + do { + return try coordinatedRead(resolvedItem.url) + } catch { + let diagnosticError = lastCoordinatedReadError?.localizedDescription ?? "none" + throw CloudStorageError.Offline( + "iCloud download timed out after \(defaultTimeout)s (last coordinated read error: \(diagnosticError), final coordinated read failed: \(error.localizedDescription))" + ) + } + } +} diff --git a/ios/Cove/FFI/WeakReconciler.swift b/ios/Cove/FFI/WeakReconciler.swift index 240edc63e..d4a736827 100644 --- a/ios/Cove/FFI/WeakReconciler.swift +++ b/ios/Cove/FFI/WeakReconciler.swift @@ -31,3 +31,38 @@ protocol AnyReconciler: AnyObject { func reconcile(message: Message) func reconcileMany(messages: [Message]) } + +protocol ReconcilingManager: AnyReconciler { + func apply(_ message: Message) + func logReconcile(message: Message) + func logReconcileMany(messages: [Message]) + var canApplyReconcileMessages: Bool { get } +} + +extension ReconcilingManager { + var canApplyReconcileMessages: Bool { + true + } + + func logReconcile(message _: Message) {} + + func logReconcileMany(messages _: [Message]) {} + + func reconcile(message: Message) { + DispatchQueue.main.async { [weak self] in + guard let self, self.canApplyReconcileMessages else { return } + + self.logReconcile(message: message) + self.apply(message) + } + } + + func reconcileMany(messages: [Message]) { + DispatchQueue.main.async { [weak self] in + guard let self, self.canApplyReconcileMessages else { return } + + self.logReconcileMany(messages: messages) + messages.forEach { self.apply($0) } + } + } +} diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportAlerts.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportAlerts.swift new file mode 100644 index 000000000..9b14f44e4 --- /dev/null +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportAlerts.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct HotWalletImportPresentationContext { + let app: AppManager + let alertState: Binding?> + let handleScan: (Result) -> Void + let onImported: ((WalletId) -> Void)? + + func dismissAlert() { + alertState.wrappedValue = nil + } +} + +extension HotWalletImportAlertState: TaggedAlertPresentable { + func alert(context: HotWalletImportPresentationContext) -> AnyAlertBuilder { + let singleOkCancel = { + Button("Ok", role: .cancel) { + context.dismissAlert() + } + } + + switch self { + case .invalidWords: + return AlertBuilder( + title: "Words not valid", + message: + "The words you entered does not create a valid wallet. Please check the words and try again.", + actions: singleOkCancel + ).eraseToAny() + + case let .duplicateWallet(walletId): + return AlertBuilder( + title: "Duplicate Wallet", + message: "This wallet has already been imported!", + actions: { + Button("OK", role: .cancel) { + context.dismissAlert() + if let onImported = context.onImported { + onImported(walletId) + return + } + + do { + try context.app.selectWalletOrThrow(walletId) + context.app.resetRoute(to: .selectedWallet(walletId)) + } catch { + context.app.alertState = TaggedItem(.general( + title: "Unable to Select Wallet", + message: error.localizedDescription + )) + } + } + } + ).eraseToAny() + + case let .scanError(error): + return AlertBuilder( + title: "Error Scanning QR Code", + message: error, + actions: singleOkCancel + ).eraseToAny() + } + } +} diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift index b56f5a25f..94aa565ac 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift @@ -9,13 +9,13 @@ import SwiftUI private let groupsOf = HotWalletImportScreen.GROUPS_OF -private enum AlertState: Equatable { +enum HotWalletImportAlertState: Equatable { case invalidWords case duplicateWallet(WalletId) case scanError(String) } -private enum SheetState: Equatable { +enum HotWalletImportSheetState: Equatable { case qrCode } @@ -118,8 +118,8 @@ struct HotWalletImportScreen: View { @State var filteredSuggestions: [String] = [] // alerts & sheets - @State private var alertState: TaggedItem? = .none - @State private var sheetState: TaggedItem? = .none + @State private var alertState: TaggedItem? = .none + @State private var sheetState: TaggedItem? = .none // qr code scanning @Environment(\.dismiss) var dismiss @@ -132,6 +132,15 @@ struct HotWalletImportScreen: View { @State private var nfcReader: NFCReader = .init() @State private var tasks: [Task] = [] + private var presentationContext: HotWalletImportPresentationContext { + HotWalletImportPresentationContext( + app: app, + alertState: $alertState, + handleScan: handleScan, + onImported: onImported + ) + } + func initOnAppear() { nfcReader = NFCReader() @@ -354,14 +363,8 @@ struct HotWalletImportScreen: View { .navigationTitle("Import Wallet") .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) - .sheet(item: $sheetState, content: SheetContent) - .alert( - alertTitle, - isPresented: showingAlert, - presenting: alertState, - actions: { MyAlert($0).actions }, - message: { MyAlert($0).message } - ) + .presentingSheet($sheetState, context: presentationContext) + .presentingAlert($alertState, context: presentationContext, defaultTitle: "Error") .modifier(ConditionalTintModifier()) .onAppear(perform: initOnAppear) .onChange(of: sheetState, initial: true) { oldState, newState in @@ -407,85 +410,6 @@ struct HotWalletImportScreen: View { .tint(.white) } - // MARK: Alerts - - private var showingAlert: Binding { - Binding( - get: { alertState != nil }, - set: { if !$0 { alertState = .none } } - ) - } - - private var alertTitle: String { - guard let alertState else { return "Error" } - return MyAlert(alertState).title - } - - private func MyAlert(_ alert: TaggedItem) -> some AlertBuilderProtocol { - let singleOkCancel = { - Button("Ok", role: .cancel) { - alertState = .none - } - } - - switch alert.item { - case .invalidWords: - return AlertBuilder( - title: "Words not valid", - message: - "The words you entered does not create a valid wallet. Please check the words and try again.", - actions: singleOkCancel - ) - case let .duplicateWallet(walletId): - return AlertBuilder( - title: "Duplicate Wallet", - message: "This wallet has already been imported!", - actions: { - Button("OK", role: .cancel) { - alertState = .none - if let onImported { - onImported(walletId) - return - } - - do { - try app.selectWalletOrThrow(walletId) - app.resetRoute(to: .selectedWallet(walletId)) - } catch { - app.alertState = TaggedItem(.general( - title: "Unable to Select Wallet", - message: error.localizedDescription - )) - } - } - } - ) - case let .scanError(error): - return AlertBuilder( - title: "Error Scanning QR Code", - message: error, - actions: singleOkCancel - ) - } - } - - // MARK: Sheet - - @ViewBuilder - private func SheetContent(_ state: TaggedItem) -> some View { - switch state.item { - case .qrCode: - ScannerView( - codeTypes: [.qr], - scanMode: .oncePerCode, - scanInterval: 0.1 - ) { response in - handleScan(result: response) - } - .ignoresSafeArea(.all) - } - } - // MARK: OnChange Functions func onChangeFocusField(_: ImportFieldNumber?, _ new: ImportFieldNumber?) { diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportSheets.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportSheets.swift new file mode 100644 index 000000000..356af2921 --- /dev/null +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportSheets.swift @@ -0,0 +1,22 @@ +import SwiftUI + +extension HotWalletImportSheetState: TaggedSheetPresentable { + func sheet(context: HotWalletImportPresentationContext) -> AnyView { + AnyView(HotWalletImportSheetContent(context: context)) + } +} + +private struct HotWalletImportSheetContent: View { + let context: HotWalletImportPresentationContext + + var body: some View { + ScannerView( + codeTypes: [.qr], + scanMode: .oncePerCode, + scanInterval: 0.1 + ) { response in + context.handleScan(response) + } + .ignoresSafeArea(.all) + } +} diff --git a/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift b/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift index ab1f80079..26880f71f 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift @@ -10,13 +10,10 @@ import SwiftUI struct MoreInfoPopover: View { @Environment(AppManager.self) private var app - // args let manager: WalletManager - @Binding var isImportingLabels: Bool - - // bindings - @Binding var showExportLabelsConfirmation: Bool - @Binding var showExportXpubConfirmation: Bool + let importLabels: () -> Void + let exportLabels: () -> Void + let exportXpub: () -> Void private var hasLabels: Bool { labelManager.hasLabels() @@ -30,18 +27,6 @@ struct MoreInfoPopover: View { manager.walletMetadata } - func importLabels() { - isImportingLabels = true - } - - func exportLabels() { - showExportLabelsConfirmation = true - } - - func exportXpub() { - showExportXpubConfirmation = true - } - func exportTransactions() { Task { do { @@ -162,9 +147,9 @@ struct MoreInfoPopover: View { AsyncPreview { MoreInfoPopover( manager: WalletManager(preview: "preview_only"), - isImportingLabels: Binding.constant(false), - showExportLabelsConfirmation: Binding.constant(false), - showExportXpubConfirmation: Binding.constant(false) + importLabels: {}, + exportLabels: {}, + exportXpub: {} ) .environment(AppManager.shared) } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift new file mode 100644 index 000000000..4d6165487 --- /dev/null +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct SelectedWalletPresentationContext { + let app: AppManager + let manager: WalletManager + let presentationState: Binding?> + let walletErrorAlert: Binding?> + let scannedLabels: Binding?> + + func dismissWalletError() { + walletErrorAlert.wrappedValue = nil + } +} + +extension WalletErrorAlert: TaggedAlertPresentable { + func alert(context: SelectedWalletPresentationContext) -> AnyAlertBuilder { + switch self { + case .nodeConnectionFailed: + AlertBuilder( + title: "Node Connection Failed", + message: "Would you like to select a different node?", + actions: { + Button("Yes, Change Node") { + context.dismissWalletError() + context.app.pushRoutes(RouteFactory().nestedSettings(route: .node)) + } + + Button("Cancel", role: .cancel) { + context.dismissWalletError() + } + } + ).eraseToAny() + + case .noBalance: + AlertBuilder( + title: "No Balance", + message: "Can't send a transaction, when you have no funds.", + actions: { + Button("Receive Funds") { + context.dismissWalletError() + context.presentationState.wrappedValue = TaggedItem(.receive) + } + + Button("Cancel", role: .cancel) { + context.dismissWalletError() + } + } + ).eraseToAny() + } + } +} diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 7f0a91404..9531c8670 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -13,11 +13,15 @@ struct ExportingBackup: Equatable { var backup: Data } -private enum SheetState: Equatable { +enum SelectedWalletPresentationState: Equatable { case receive case chooseAddressType([FoundAddress]) - case qrLabelsExport case qrLabelsImport + case labelsFileImport + case exportLabelsConfirmation + case labelsQrExport + case exportXpubConfirmation + case xpubQrExport } struct SelectedWalletScreen: View { @@ -37,8 +41,7 @@ struct SelectedWalletScreen: View { /// public var manager: WalletManager - /// alerts & sheets - @State private var sheetState: TaggedItem? = nil + @State private var presentationState: TaggedItem? = nil @State private var showingCopiedPopup = true @State private var shouldShowNavBar = false @@ -48,11 +51,6 @@ struct SelectedWalletScreen: View { @State var exportingBackup: ExportingBackup? = nil @State private var scannedLabels: TaggedItem? = nil - @State private var isImportingLabels = false - @State private var showExportLabelsConfirmation = false - @State private var showLabelsQrExport = false - @State private var showExportXpubConfirmation = false - @State private var showXpubQrExport = false @State private var pendingRenameNavigationTask: Task? = nil /// private @@ -107,33 +105,6 @@ struct SelectedWalletScreen: View { .background(Color.coveBg) } - func DisplayErrorAlert(_ alert: WalletErrorAlert) -> Alert { - switch alert { - case .nodeConnectionFailed: - Alert( - title: Text("Node Connection Failed"), - message: Text("Would you like to select a different node?"), - primaryButton: .default( - Text("Yes, Change Node"), - action: { - app.pushRoutes(RouteFactory().nestedSettings(route: .node)) - } - ), - secondaryButton: .cancel() - ) - case .noBalance: - .init( - title: Text("No Balance"), - message: Text("Can't send a transaction, when you have no funds."), - primaryButton: .default( - Text("Receive Funds"), - action: { sheetState = .init(.receive) } - ), - secondaryButton: .cancel() - ) - } - } - @ViewBuilder var Transactions: some View { switch manager.loadState { @@ -161,18 +132,58 @@ struct SelectedWalletScreen: View { } } - @ViewBuilder - private func SheetContent(_ state: TaggedItem) -> some View { - switch state.item { - case .receive: - ReceiveView(manager: manager) - case let .chooseAddressType(foundAddresses): - ChooseWalletTypeView(manager: manager, foundAddresses: foundAddresses) - case .qrLabelsExport: - EmptyView() - case .qrLabelsImport: - QrCodeLabelImportView(scannedCode: $scannedLabels) - } + private var presentationContext: SelectedWalletPresentationContext { + SelectedWalletPresentationContext( + app: app, + manager: manager, + presentationState: $presentationState, + walletErrorAlert: Binding( + get: { manager.errorAlert }, + set: { manager.errorAlert = $0 } + ), + scannedLabels: $scannedLabels + ) + } + + private var sheetPresentationState: Binding?> { + Binding( + get: { + guard let presentationState, presentationState.item.isSheet else { return nil } + return presentationState + }, + set: { newValue in + if let newValue { + presentationState = newValue + } else if presentationState?.item.isSheet == true { + presentationState = nil + } + } + ) + } + + private var labelsFileImportIsPresented: Binding { + isPresenting(.labelsFileImport) + } + + private var exportLabelsConfirmationIsPresented: Binding { + isPresenting(.exportLabelsConfirmation) + } + + private var exportXpubConfirmationIsPresented: Binding { + isPresenting(.exportXpubConfirmation) + } + + private func isPresenting(_ state: SelectedWalletPresentationState) -> Binding { + Binding( + get: { presentationState?.item == state }, + set: { isPresented in + if isPresented { + presentationState = TaggedItem(state) + } else if presentationState?.item == state { + presentationState = nil + } + } + ) } private func setSheetState(_ discoveryState: DiscoveryState) { @@ -180,23 +191,23 @@ struct SelectedWalletScreen: View { switch discoveryState { case let .foundAddressesFromMnemonic(foundAddresses): - sheetState = TaggedItem(.chooseAddressType(foundAddresses)) + presentationState = TaggedItem(.chooseAddressType(foundAddresses)) case let .foundAddressesFromJson(foundAddress, _): - sheetState = TaggedItem(.chooseAddressType(foundAddress)) + presentationState = TaggedItem(.chooseAddressType(foundAddress)) default: () } } func showReceiveSheet() { - sheetState = TaggedItem(.receive) + presentationState = TaggedItem(.receive) } func showQrExport() { - showLabelsQrExport = true + presentationState = TaggedItem(.labelsQrExport) } func presentXpubQrExport() { - showXpubQrExport = true + presentationState = TaggedItem(.xpubQrExport) } private func showRenameFromTitleMenu() { @@ -277,9 +288,15 @@ struct SelectedWalletScreen: View { Menu { MoreInfoPopover( manager: manager, - isImportingLabels: $isImportingLabels, - showExportLabelsConfirmation: $showExportLabelsConfirmation, - showExportXpubConfirmation: $showExportXpubConfirmation + importLabels: { + presentationState = TaggedItem(.labelsFileImport) + }, + exportLabels: { + presentationState = TaggedItem(.exportLabelsConfirmation) + }, + exportXpub: { + presentationState = TaggedItem(.exportXpubConfirmation) + } ) } label: { Image(systemName: "ellipsis.circle") @@ -289,7 +306,7 @@ struct SelectedWalletScreen: View { .accessibilityIdentifier("selectedWallet.more") .confirmationDialog( "Export Labels", - isPresented: $showExportLabelsConfirmation + isPresented: exportLabelsConfirmationIsPresented ) { Button("QR Code") { showQrExport() @@ -303,7 +320,7 @@ struct SelectedWalletScreen: View { } .confirmationDialog( "Export Xpub", - isPresented: $showExportXpubConfirmation + isPresented: exportXpubConfirmationIsPresented ) { Button("QR Code") { presentXpubQrExport() @@ -353,9 +370,9 @@ struct SelectedWalletScreen: View { showNavBar: shouldShowNavBar, reduceTransparency: reduceTransparency ) - .sheet(item: $sheetState, content: SheetContent) + .presentingSheet(sheetPresentationState, context: presentationContext) .fileImporter( - isPresented: $isImportingLabels, + isPresented: labelsFileImportIsPresented, allowedContentTypes: [.plainText, .json] ) { result in do { @@ -381,34 +398,6 @@ struct SelectedWalletScreen: View { ) } } - .sheet(isPresented: $showLabelsQrExport) { - QrExportView( - title: "Export Labels", - subtitle: "Scan to import labels\ninto another wallet", - generateBbqrStrings: { density in - try await manager.rust.exportLabelsForQr(density: density) - }, - generateUrStrings: nil, - copyData: { try await manager.rust.exportLabelsForShare().content } - ) - .presentationDetents([.height(500), .height(600), .large]) - .padding() - .padding(.top, 10) - } - .sheet(isPresented: $showXpubQrExport) { - QrExportView( - title: "Export Xpub", - subtitle: "Public descriptor for\nwatch-only wallet", - generateBbqrStrings: { density in - try await manager.rust.exportXpubForQr(density: density) - }, - generateUrStrings: nil, - copyData: { try await manager.rust.exportXpubForShare().content } - ) - .presentationDetents([.height(500), .height(600), .large]) - .padding() - .padding(.top, 10) - } .onChange(of: scannedLabels, initial: false, onChangeOfScannedLabels) } @@ -536,14 +525,26 @@ struct SelectedWalletScreen: View { app.isPastHeader = false UIRefreshControl.appearance().tintColor = UIColor.secondaryLabel } - .alert( - item: Binding(get: { manager.errorAlert }, set: { manager.errorAlert = $0 }), - content: DisplayErrorAlert + .presentingAlert( + Binding(get: { manager.errorAlert }, set: { manager.errorAlert = $0 }), + context: presentationContext, + defaultTitle: "Error" ) .environment(manager) } } +extension SelectedWalletPresentationState { + var isSheet: Bool { + switch self { + case .receive, .chooseAddressType, .qrLabelsImport, .labelsQrExport, .xpubQrExport: + true + case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation: + false + } + } +} + struct VerifyReminder: View { @Environment(\.navigate) private var navigate let walletId: WalletId diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift new file mode 100644 index 000000000..5a98d090d --- /dev/null +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift @@ -0,0 +1,56 @@ +import SwiftUI + +extension SelectedWalletPresentationState: TaggedSheetPresentable { + func sheet(context: SelectedWalletPresentationContext) -> AnyView { + AnyView(SelectedWalletSheetContent(sheet: self, context: context)) + } +} + +private struct SelectedWalletSheetContent: View { + let sheet: SelectedWalletPresentationState + let context: SelectedWalletPresentationContext + + var body: some View { + switch sheet { + case .receive: + ReceiveView(manager: context.manager) + + case let .chooseAddressType(foundAddresses): + ChooseWalletTypeView(manager: context.manager, foundAddresses: foundAddresses) + + case .qrLabelsImport: + QrCodeLabelImportView(scannedCode: context.scannedLabels) + + case .labelsQrExport: + QrExportView( + title: "Export Labels", + subtitle: "Scan to import labels\ninto another wallet", + generateBbqrStrings: { density in + try await context.manager.rust.exportLabelsForQr(density: density) + }, + generateUrStrings: nil, + copyData: { try await context.manager.rust.exportLabelsForShare().content } + ) + .presentationDetents([.height(500), .height(600), .large]) + .padding() + .padding(.top, 10) + + case .xpubQrExport: + QrExportView( + title: "Export Xpub", + subtitle: "Public descriptor for\nwatch-only wallet", + generateBbqrStrings: { density in + try await context.manager.rust.exportXpubForQr(density: density) + }, + generateUrStrings: nil, + copyData: { try await context.manager.rust.exportXpubForShare().content } + ) + .presentationDetents([.height(500), .height(600), .large]) + .padding() + .padding(.top, 10) + + case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation: + EmptyView() + } + } +} diff --git a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/ReceivedDetailsExpandedView.swift b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/ReceivedDetailsExpandedView.swift index 0451859be..9515a7cf1 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/ReceivedDetailsExpandedView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/ReceivedDetailsExpandedView.swift @@ -48,7 +48,7 @@ struct ReceivedDetailsExpandedView: View { .multilineTextAlignment(.leading) if let numberOfConfirmations { - Text(ThousandsFormatter(numberOfConfirmations).fmt()) + Text(manager.displayConfirmationCount(UInt32(numberOfConfirmations))) .fontWeight(.semibold) .multilineTextAlignment(.leading) .contentTransition(.numericText()) diff --git a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/SentDetailsExpandedView.swift b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/SentDetailsExpandedView.swift index 759930554..eda8aef5b 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/SentDetailsExpandedView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/SentDetailsExpandedView.swift @@ -57,7 +57,7 @@ struct SentDetailsExpandedView: View { if let numberOfConfirmations { Group { - Text(ThousandsFormatter(numberOfConfirmations).fmt()) + Text(manager.displayConfirmationCount(UInt32(numberOfConfirmations))) .contentTransition(.numericText()) Image(systemName: "checkmark.circle.fill") diff --git a/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift b/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift index b8382140b..8a1fc968a 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift @@ -50,7 +50,7 @@ struct WalletBalanceHeaderView: View { } if balance.asSats() == 0 { - manager.errorAlert = .noBalance + manager.errorAlert = TaggedItem(.noBalance) return } diff --git a/ios/Cove/Flows/SendFlow/SendFlowAlerts.swift b/ios/Cove/Flows/SendFlow/SendFlowAlerts.swift new file mode 100644 index 000000000..7a23a4db6 --- /dev/null +++ b/ios/Cove/Flows/SendFlow/SendFlowAlerts.swift @@ -0,0 +1,242 @@ +import SwiftUI + +enum SendFlowConfirmAlertState: Equatable { + case sent(WalletId) + case broadcastError(String) +} + +struct SendFlowErrorAlertContext { + let alertState: Binding?> + + func dismissAlert() { + alertState.wrappedValue = nil + } +} + +struct SendFlowConfirmAlertContext { + let presenter: SendFlowPresenter + let sendState: Binding + + func dismissAlert() { + presenter.confirmationAlertState = nil + } +} + +struct SendFlowAlertContext { + let presenter: SendFlowPresenter + let sendFlowManager: SendFlowManager + + func dismissAlert() { + presenter.clearAlert() + } +} + +extension SendFlowAlertState: TaggedAlertPresentable { + func alert(context: SendFlowAlertContext) -> AnyAlertBuilder { + AlertBuilder( + title: title, + message: { Text(message) }, + actions: { SendFlowAlertActions(alert: self, context: context) } + ).eraseToAny() + } +} + +extension SendFlowErrorAlert: TaggedAlertPresentable { + func alert(context: SendFlowErrorAlertContext) -> AnyAlertBuilder { + AlertBuilder( + title: "Error!", + message: message, + actions: { + Button("OK") { + context.dismissAlert() + } + } + ).eraseToAny() + } +} + +extension SendFlowConfirmAlertState: TaggedAlertPresentable { + func alert(context: SendFlowConfirmAlertContext) -> AnyAlertBuilder { + switch self { + case let .sent(walletId): + AlertBuilder( + title: "Sent!", + message: "Transaction was successfully sent!", + actions: { + Button("OK") { + context.dismissAlert() + context.presenter.app.loadAndReset(to: Route.selectedWallet(walletId)) + } + } + ).eraseToAny() + + case let .broadcastError(error): + AlertBuilder( + title: "Error Broadcasting!", + message: error, + actions: { + Button("OK") { + context.sendState.wrappedValue = .idle + context.dismissAlert() + } + } + ).eraseToAny() + } + } +} + +private struct SendFlowAlertActions: View { + let alert: SendFlowAlertState + let context: SendFlowAlertContext + + private var presenter: SendFlowPresenter { + context.presenter + } + + var body: some View { + switch alert { + case let .error(error): + errorButtons(error) + case .general: + Button("OK") { context.dismissAlert() } + case let .warning(kind: kind, title: _, message: _): + Button("Send Anyway") { + context.sendFlowManager.dispatch(action: .acknowledgeWarningAndFinalize(kind)) + } + Button("Cancel", role: .cancel) { + context.dismissAlert() + } + } + } + + @ViewBuilder + private func errorButtons(_ error: SendFlowError) -> some View { + switch error { + case .EmptyAddress, .WrongNetwork, .InvalidAddress: + Button("OK") { + context.dismissAlert() + presenter.focusField = .address + } + + case .NoBalance: + Button("Go Back") { + context.dismissAlert() + presenter.app.popRoute() + } + + case .InvalidNumber, + .InsufficientFunds, + .SendBelowDustLimit, + .ZeroAmount, + .WalletManager, + .UnableToGetFeeDetails, + .UnableToGetFeeRate, + .UnableToBuildTxn, + .UnableToSaveUnsignedTransaction, + .UnableToGetMaxSend: + Button("OK") { + presenter.focusField = .amount + context.dismissAlert() + } + } + } +} + +private extension SendFlowAlertState { + var title: String { + switch self { + case let .error(error): + error.title + case let .general(title: title, message: _): + title + case let .warning(kind: _, title: title, message: _): + title + } + } + + var message: String { + switch self { + case let .error(error): + error.message + case let .general(title: _, message: message): + message + case let .warning(kind: _, title: _, message: message): + message + } + } +} + +private extension SendFlowErrorAlert { + var message: String { + switch self { + case let .confirmDetails(error): + error + case let .signAndBroadcast(error): + error + } + } +} + +private extension SendFlowError { + var title: String { + switch self { + case .EmptyAddress, .InvalidAddress, .WrongNetwork: + "Invalid Address" + case .InvalidNumber, .ZeroAmount: + "Invalid Amount" + case .InsufficientFunds, .NoBalance: + "Insufficient Funds" + case .SendBelowDustLimit: + "Amount Below Dust Limit" + case .UnableToGetFeeRate: + "Unable to get fee rate" + case .UnableToBuildTxn: + "Unable to build transaction" + case .UnableToGetMaxSend: + "Unable to get max send" + case .UnableToSaveUnsignedTransaction: + "Unable to Save Unsigned Transaction" + case .WalletManager(.LockedOutputsSelected): + "Insufficient Funds" + case .WalletManager: + "Error" + case .UnableToGetFeeDetails: + "Fee Details Error" + } + } + + var message: String { + switch self { + case .EmptyAddress: + "Please enter an address" + case .InvalidNumber: + "Please enter a valid number for the amout to send" + case .ZeroAmount: + "Can't send an empty transaction. Please enter a valid amount" + case .NoBalance: + "You do not have any bitcoin in your wallet. Please add some to send a transaction" + case let .InvalidAddress(address): + "The address \(address) is invalid" + case let .WrongNetwork(address: address, validFor: validFor, current: currentNetwork): + "The address \(address) is on the wrong network, is it for (\(validFor). You are on \(currentNetwork)" + case .InsufficientFunds: + "You do not have enough bitcoin in your wallet to cover the amount plus fees" + case .SendBelowDustLimit: + "This amount is below Bitcoin's dust limit for this address type. The network would reject it. Please send a bit more." + case .UnableToGetFeeRate: + "Are you connected to the internet?" + case .WalletManager(.LockedOutputsSelected): + "Selected coins include locked coins. Unlock them or choose different coins." + case let .WalletManager(msg): + msg.description + case let .UnableToGetFeeDetails(msg): + msg + case let .UnableToBuildTxn(msg): + msg + case let .UnableToGetMaxSend(msg): + msg + case let .UnableToSaveUnsignedTransaction(msg): + msg + } + } +} diff --git a/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift b/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift index d753dd9e8..94fb7c769 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowCoinControlSetAmountScreen.swift @@ -250,16 +250,12 @@ struct SendFlowCoinControlSetAmountScreen: View { .sheet(isPresented: $customAmountSheetIsPresented) { SendFlowUtxoCustomAmountSheetView(utxos: utxos) } - .alert( - presenter.alertTitle, - isPresented: presenter.showingAlert, - presenting: presenter.alertState, - actions: { alert in - presenter.alertButtons(alert: alert) { kind in - sendFlowManager.dispatch(action: .acknowledgeWarningAndFinalize(kind)) - } - }, - message: presenter.alertMessage + .presentingAlert( + presenter.alertStateBinding, + context: SendFlowAlertContext( + presenter: presenter, + sendFlowManager: sendFlowManager + ) ) } diff --git a/ios/Cove/Flows/SendFlow/SendFlowConfirmScreen.swift b/ios/Cove/Flows/SendFlow/SendFlowConfirmScreen.swift index 5cfae9982..0eae3c1a7 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowConfirmScreen.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowConfirmScreen.swift @@ -11,6 +11,7 @@ import SwiftUI struct SendFlowConfirmScreen: View { @Environment(AppManager.self) private var app @Environment(AuthManager.self) private var auth + @Environment(SendFlowPresenter.self) private var presenter let id: WalletId @State var manager: WalletManager @@ -22,9 +23,7 @@ struct SendFlowConfirmScreen: View { // private @State private var finalizedTransaction: BitcoinTransaction? = nil - @State private var isShowingAlert = false @State private var sendState: SendState = .idle - @State private var isShowingErrorAlert = false /// popover to change btc and sats @State private var showingMenu: Bool = false @@ -46,6 +45,10 @@ struct SendFlowConfirmScreen: View { manager.walletMetadata } + private var confirmationAlertContext: SendFlowConfirmAlertContext { + SendFlowConfirmAlertContext(presenter: presenter, sendState: $sendState) + } + var body: some View { // signed psbt has not been finalized yet if case let .signedPsbt(psbt) = input, finalizedTransaction == nil { @@ -187,20 +190,22 @@ struct SendFlowConfirmScreen: View { // for payjoin, stay in .sending — PayjoinTxBroadcast reconcile fires sendState = .sent if payjoinEndpoint == nil { sendState = .sent - isShowingAlert = true + presenter.confirmationAlertState = .init(.sent(id)) auth.unlock() } return } sendState = .sent - isShowingAlert = true + presenter.confirmationAlertState = .init(.sent(id)) auth.unlock() } catch let error as WalletManagerError { sendState = .error(error.description) - isShowingErrorAlert = true + presenter.confirmationAlertState = .init(.broadcastError(error.description)) } catch { sendState = .error(error.localizedDescription) - isShowingErrorAlert = true + presenter.confirmationAlertState = .init( + .broadcastError(error.localizedDescription) + ) } } } @@ -228,7 +233,7 @@ struct SendFlowConfirmScreen: View { // UUID changes each time so this fires reliably across multiple sends guard uuid != nil, case .sending = sendState else { return } sendState = .sent - isShowingAlert = true + presenter.confirmationAlertState = .init(.sent(id)) auth.unlock() } .onChange(of: manager.sendFlowErrorAlert) { _, alert in @@ -241,38 +246,13 @@ struct SendFlowConfirmScreen: View { case let .confirmDetails(error): error } sendState = .error(errorMessage) - isShowingErrorAlert = true + manager.sendFlowErrorAlert = nil + presenter.confirmationAlertState = .init(.broadcastError(errorMessage)) } - .alert( - "Sent!", - isPresented: $isShowingAlert, - actions: { - Button("OK") { - app.loadAndReset(to: Route.selectedWallet(id)) - } - }, - message: { - Text("Transaction was successfully sent!") - } - ) - .alert( - "Error Broadcasting!", - isPresented: $isShowingErrorAlert, - actions: { - Button("OK") { - sendState = .idle - isShowingErrorAlert = false - } - }, - message: { - if case let .error(error) = sendState { - Text(error) - } else { - Text( - "Unknown error, unable to broadcast transaction, please try again!" - ) - } - } + .presentingAlert( + presenter.confirmationAlertStateBinding, + context: confirmationAlertContext, + defaultTitle: "Error Broadcasting!" ) } } @@ -314,6 +294,9 @@ private enum SendConfirmationError: LocalizedError { ) .environment(AppManager.shared) .environment(AuthManager.shared) + .environment( + SendFlowPresenter(app: AppManager.shared, manager: manager) + ) } } } @@ -340,5 +323,11 @@ private enum SendConfirmationError: LocalizedError { ) .environment(AppManager.shared) .environment(AuthManager.shared) + .environment( + SendFlowPresenter( + app: AppManager.shared, + manager: WalletManager(preview: "preview_only") + ) + ) } } diff --git a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift index 82f7f5150..6c6036387 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift @@ -89,12 +89,10 @@ private struct SendFlowLoadedView: View { return } } - .alert( - alertTitle, - isPresented: showingAlert, - presenting: manager.sendFlowErrorAlert, - actions: { myAlert($0).actions }, - message: { myAlert($0).message } + .presentingAlert( + sendFlowErrorAlert, + context: SendFlowErrorAlertContext(alertState: sendFlowErrorAlert), + defaultTitle: "Error!" ) .onDisappear { presenter.setDisappearing() @@ -123,35 +121,13 @@ private struct SendFlowLoadedView: View { } } - private var showingAlert: Binding { + private var sendFlowErrorAlert: Binding?> { Binding( - get: { manager.sendFlowErrorAlert != nil }, - set: { if !$0 { manager.sendFlowErrorAlert = .none } } + get: { manager.sendFlowErrorAlert }, + set: { manager.sendFlowErrorAlert = $0 } ) } - private var alertTitle: String { - guard let alert = manager.sendFlowErrorAlert else { return "Error!" } - return myAlert(alert).title - } - - private func myAlert(_ alert: TaggedItem) -> AnyAlertBuilder { - let error = - switch alert.item { - case let .confirmDetails(error): error - case let .signAndBroadcast(error): error - } - - return - AlertBuilder( - title: "Error!", - message: error, - actions: { - Button("OK", action: { manager.sendFlowErrorAlert = .none }) - } - ).eraseToAny() - } - private func applyRouteArguments(to sendFlowManager: SendFlowManager) { switch sendRoute { case let .setAmount(id: _, address: address, amount: amount): diff --git a/ios/Cove/Flows/SendFlow/SendFlowHardwareAlerts.swift b/ios/Cove/Flows/SendFlow/SendFlowHardwareAlerts.swift new file mode 100644 index 000000000..2ce29126d --- /dev/null +++ b/ios/Cove/Flows/SendFlow/SendFlowHardwareAlerts.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct SendFlowHardwarePresentationContext { + let manager: WalletManager + let details: ConfirmDetails + let alertState: Binding?> + let inputOutputDetailsPresentationSize: Binding + + func dismissAlert() { + alertState.wrappedValue = nil + } +} + +extension SendFlowHardwareAlertState: TaggedAlertPresentable { + func alert(context: SendFlowHardwarePresentationContext) -> AnyAlertBuilder { + let singleOkCancel = { + Button("Ok", role: .cancel) { + context.dismissAlert() + } + } + + switch self { + case let .bbqrError(message): + return AlertBuilder( + title: "QR Error", + message: "Unable to create BBQr: \(message)", + actions: singleOkCancel + ).eraseToAny() + + case let .fileError(message): + return AlertBuilder( + title: "File Import Error", + message: message, + actions: singleOkCancel + ).eraseToAny() + + case let .nfcError(error): + return AlertBuilder( + title: "NFC Error", + message: error, + actions: singleOkCancel + ).eraseToAny() + + case let .pasteError(error): + return AlertBuilder( + title: "Paste Error", + message: error, + actions: singleOkCancel + ).eraseToAny() + } + } +} diff --git a/ios/Cove/Flows/SendFlow/SendFlowHardwareScreen.swift b/ios/Cove/Flows/SendFlow/SendFlowHardwareScreen.swift index 86c7fc892..17b785381 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowHardwareScreen.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowHardwareScreen.swift @@ -8,23 +8,18 @@ import Foundation import SwiftUI -private enum SheetState: Equatable { +enum SendFlowHardwareSheetState: Equatable { case details case inputOutputDetails case exportQr } -private enum DetailsSheetState: Equatable { - case main - case inputOutputDetails -} - private enum ConfirmationState: Equatable { case exportTxn case importSignature } -private enum AlertState: Equatable { +enum SendFlowHardwareAlertState: Equatable { case bbqrError(String) case fileError(String) case nfcError(String) @@ -40,8 +35,8 @@ struct SendFlowHardwareScreen: View { let prices: PriceResponse? = nil // sheets, alerts, confirmations - @State private var alertState: TaggedItem? = .none - @State private var sheetState: TaggedItem? = .none + @State private var alertState: TaggedItem? = .none + @State private var sheetState: TaggedItem? = .none @State private var confirmationState: TaggedItem? = .none @State private var inputOutputDetailsPresentationSize: PresentationDetent = .height(300) @@ -62,6 +57,15 @@ struct SendFlowHardwareScreen: View { return manager.rust.convertAndDisplayFiat(amount: amount, prices: prices) } + private var presentationContext: SendFlowHardwarePresentationContext { + SendFlowHardwarePresentationContext( + manager: manager, + details: details, + alertState: $alertState, + inputOutputDetailsPresentationSize: $inputOutputDetailsPresentationSize + ) + } + var body: some View { VStack(spacing: 0) { // MARK: HEADER @@ -212,19 +216,13 @@ struct SendFlowHardwareScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal) .background(Color.background) - .sheet(item: $sheetState, content: SheetContent) + .presentingSheet($sheetState, context: presentationContext) .confirmationDialog( confirmationDialogTitle, isPresented: confirmationDialogIsPresented, actions: ConfirmationDialogView ) - .alert( - alertTitle, - isPresented: showingAlert, - presenting: alertState, - actions: { MyAlert($0).actions }, - message: { MyAlert($0).message } - ) + .presentingAlert($alertState, context: presentationContext, defaultTitle: "Error") .fileImporter( isPresented: $isPresentingFilePicker, allowedContentTypes: [.plainText, .psbt, .txn], @@ -394,78 +392,6 @@ struct SendFlowHardwareScreen: View { case .none: EmptyView() } } - - // MARK: Sheet - - @ViewBuilder - private func SheetContent(_ state: TaggedItem) -> some View { - switch state.item { - case .details: - SendFlowDetailsSheetView(manager: manager, details: details) - .presentationDetents([.height(425), .height(600), .large]) - .padding() - case .inputOutputDetails: - SendFlowAdvancedDetailsView(manager: manager, details: details) - .presentationDetents( - [.height(300), .height(400), .height(500), .large], - selection: $inputOutputDetailsPresentationSize - ) - case .exportQr: - QrExportView(details: details) - .presentationDetents([.height(550), .height(650), .large]) - .padding() - .padding(.top, 10) - } - } - - // MARK: Alerts - - var showingAlert: Binding { - Binding( - get: { alertState != nil }, - set: { if !$0 { alertState = .none } } - ) - } - - private var alertTitle: String { - guard let alertState else { return "Error" } - return MyAlert(alertState).title - } - - private func MyAlert(_ alert: TaggedItem) -> some AlertBuilderProtocol { - let singleOkCancel = { - Button("Ok", role: .cancel) { - alertState = .none - } - } - - switch alert.item { - case let .bbqrError(message): - return AlertBuilder( - title: "QR Error", - message: "Unable to create BBQr: \(message)", - actions: singleOkCancel - ) - case let .fileError(message): - return AlertBuilder( - title: "File Import Error", - message: message, - actions: singleOkCancel - ) - case let .nfcError(error): - return AlertBuilder( - title: "NFC Error", - message: error, - actions: singleOkCancel - ) - case let .pasteError(error): - return AlertBuilder( - title: "Paste Error", - message: error, - actions: singleOkCancel - ) - } - } } #Preview { diff --git a/ios/Cove/Flows/SendFlow/SendFlowHardwareSheets.swift b/ios/Cove/Flows/SendFlow/SendFlowHardwareSheets.swift new file mode 100644 index 000000000..a4e831878 --- /dev/null +++ b/ios/Cove/Flows/SendFlow/SendFlowHardwareSheets.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension SendFlowHardwareSheetState: TaggedSheetPresentable { + func sheet(context: SendFlowHardwarePresentationContext) -> AnyView { + AnyView(SendFlowHardwareSheetContent(sheet: self, context: context)) + } +} + +private struct SendFlowHardwareSheetContent: View { + let sheet: SendFlowHardwareSheetState + let context: SendFlowHardwarePresentationContext + + var body: some View { + switch sheet { + case .details: + SendFlowDetailsSheetView(manager: context.manager, details: context.details) + .presentationDetents([.height(425), .height(600), .large]) + .padding() + + case .inputOutputDetails: + SendFlowAdvancedDetailsView(manager: context.manager, details: context.details) + .presentationDetents( + [.height(300), .height(400), .height(500), .large], + selection: context.inputOutputDetailsPresentationSize + ) + + case .exportQr: + QrExportView(details: context.details) + .presentationDetents([.height(550), .height(650), .large]) + .padding() + .padding(.top, 10) + } + } +} diff --git a/ios/Cove/Flows/SendFlow/SendFlowManager.swift b/ios/Cove/Flows/SendFlow/SendFlowManager.swift index 31b4ae8f1..cb936382b 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowManager.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowManager.swift @@ -10,7 +10,7 @@ import SwiftUI extension WeakReconciler: SendFlowManagerReconciler where Reconciler == SendFlowManager {} -@Observable final class SendFlowManager: AnyReconciler, SendFlowManagerReconciler { +@Observable final class SendFlowManager: ReconcilingManager, SendFlowManagerReconciler { typealias Message = SendFlowManagerReconcileMessage typealias Action = SendFlowManagerAction @@ -121,7 +121,7 @@ extension WeakReconciler: SendFlowManagerReconciler where Reconciler == SendFlow ) } - private func apply(_ message: Message) { + func apply(_ message: Message) { switch message { case let .updateAmountFiat(fiat): self.fiatAmount = fiat @@ -182,20 +182,12 @@ extension WeakReconciler: SendFlowManagerReconciler where Reconciler == SendFlow private let rustBridge = DispatchQueue( label: "cove.SendFlowManager.rustbridge", qos: .userInitiated ) - func reconcile(message: Message) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("reconcile: \(message)") - apply(message) - } + func logReconcile(message: Message) { + logger.debug("reconcile: \(message)") } - func reconcileMany(messages: [Message]) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("reconcile_messages: \(messages)") - messages.forEach { self.apply($0) } - } + func logReconcileMany(messages: [Message]) { + logger.debug("reconcile_messages: \(messages)") } public func dispatch(action: Action) { diff --git a/ios/Cove/Flows/SendFlow/SendFlowPresenter.swift b/ios/Cove/Flows/SendFlow/SendFlowPresenter.swift index 719506cbe..521625535 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowPresenter.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowPresenter.swift @@ -26,6 +26,7 @@ import SwiftUI var focusField: SetAmountFocusField? var sheetState: TaggedItem? = .none var alertState: TaggedItem? = .none + var confirmationAlertState: TaggedItem? = .none var lastWorkingFeeRate: Float? var erroredFeeRate: Float? @@ -40,10 +41,26 @@ import SwiftUI case fee } - var showingAlert: Binding { + var alertStateBinding: Binding?> { Binding( - get: { self.alertState != nil && !self.disappearing }, - set: { if !$0 { self.clearAlert() }} + get: { + if self.disappearing { return nil } + return self.alertState + }, + set: { newValue in + if newValue == nil { + self.clearAlert() + } else { + self.alertState = newValue + } + } + ) + } + + var confirmationAlertStateBinding: Binding?> { + Binding( + get: { self.confirmationAlertState }, + set: { self.confirmationAlertState = $0 } ) } @@ -56,19 +73,6 @@ import SwiftUI ) } - var alertTitle: String { - switch alertState?.item { - case let .error(error): - errorAlertTitle(error) - case let .some(.general(title: title, message: _)): - title - case let .some(.warning(kind: _, title: title, message: _)): - title - case .none: - "" - } - } - func setDisappearing() { disappearingResetWorkItem?.cancel() self.disappearing = true @@ -87,124 +91,4 @@ import SwiftUI alertState = .none } - - private func errorAlertTitle(_ error: SendFlowError) -> String { - switch error { - case .EmptyAddress, .InvalidAddress, .WrongNetwork: - "Invalid Address" - case .InvalidNumber, .ZeroAmount: "Invalid Amount" - case .InsufficientFunds, .NoBalance: "Insufficient Funds" - case .SendBelowDustLimit: "Amount Below Dust Limit" - case .UnableToGetFeeRate: "Unable to get fee rate" - case .UnableToBuildTxn: "Unable to build transaction" - case .UnableToGetMaxSend: - "Unable to get max send" - case .UnableToSaveUnsignedTransaction: - "Unable to Save Unsigned Transaction" - case .WalletManager(.LockedOutputsSelected): - "Insufficient Funds" - case .WalletManager: - "Error" - case .UnableToGetFeeDetails: - "Fee Details Error" - } - } - - @ViewBuilder - func alertMessage(alert: TaggedItem) -> some View { - switch alert.item { - case let .error(error): - Text(errorAlertMessage(error)) - case let .general(title: _, message: message): - Text(message) - case let .warning(kind: _, title: _, message: message): - Text(message) - } - } - - private func errorAlertMessage(_ error: SendFlowError) -> String { - switch error { - case .EmptyAddress: - "Please enter an address" - case .InvalidNumber: - "Please enter a valid number for the amout to send" - case .ZeroAmount: - "Can't send an empty transaction. Please enter a valid amount" - case .NoBalance: - "You do not have any bitcoin in your wallet. Please add some to send a transaction" - case let .InvalidAddress(address): - "The address \(address) is invalid" - case let .WrongNetwork(address: address, validFor: validFor, current: currentNetwork): - "The address \(address) is on the wrong network, is it for (\(validFor). You are on \(currentNetwork)" - case .InsufficientFunds: - "You do not have enough bitcoin in your wallet to cover the amount plus fees" - case .SendBelowDustLimit: - "This amount is below Bitcoin's dust limit for this address type. The network would reject it. Please send a bit more." - case .UnableToGetFeeRate: - "Are you connected to the internet?" - case .WalletManager(.LockedOutputsSelected): - "Selected coins include locked coins. Unlock them or choose different coins." - case let .WalletManager(msg): - msg.description - case let .UnableToGetFeeDetails(msg): - msg - case let .UnableToBuildTxn(msg): - msg - case let .UnableToGetMaxSend(msg): - msg - case let .UnableToSaveUnsignedTransaction(msg): - msg - } - } - - @ViewBuilder - func alertButtons( - alert: TaggedItem, - acknowledgeWarning: @escaping (SendFlowWarningKind) -> Void - ) -> some View { - switch alert.item { - case let .error(error): - errorAlertButtons(error) - case .general: - Button("OK") { self.clearAlert() } - case let .warning(kind: kind, title: _, message: _): - Button("Send Anyway") { - acknowledgeWarning(kind) - } - Button("Cancel", role: .cancel) { - self.clearAlert() - } - } - } - - @ViewBuilder - private func errorAlertButtons(_ error: SendFlowError) -> some View { - switch error { - case .EmptyAddress, .WrongNetwork, .InvalidAddress: - Button("OK") { - self.clearAlert() - self.focusField = .address - } - case .NoBalance: - Button("Go Back") { - self.clearAlert() - self.app.popRoute() - } - case .InvalidNumber, .InsufficientFunds, .SendBelowDustLimit, .ZeroAmount, .WalletManager, .UnableToGetFeeDetails: - Button("OK") { - self.focusField = .amount - self.clearAlert() - } - case .UnableToGetFeeRate, .UnableToBuildTxn, .UnableToSaveUnsignedTransaction: - Button("OK") { - self.focusField = .amount - self.clearAlert() - } - case .UnableToGetMaxSend: - Button("OK") { - self.focusField = .amount - self.clearAlert() - } - } - } } diff --git a/ios/Cove/Flows/SendFlow/SendFlowSetAmountScreen.swift b/ios/Cove/Flows/SendFlow/SendFlowSetAmountScreen.swift index 9c8ff1dba..b40c25d5b 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowSetAmountScreen.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowSetAmountScreen.swift @@ -281,16 +281,12 @@ struct SendFlowSetAmountScreen: View { } } .sheet(item: presenter.sheetStateBinding, content: SheetContent) - .alert( - presenter.alertTitle, - isPresented: presenter.showingAlert, - presenting: presenter.alertState, - actions: { alert in - presenter.alertButtons(alert: alert) { kind in - sendFlowManager.dispatch(action: .acknowledgeWarningAndFinalize(kind)) - } - }, - message: presenter.alertMessage + .presentingAlert( + presenter.alertStateBinding, + context: SendFlowAlertContext( + presenter: presenter, + sendFlowManager: sendFlowManager + ) ) } diff --git a/ios/Cove/Flows/SendFlow/SetAmountScreen/SendFlowSelectFeeRateView.swift b/ios/Cove/Flows/SendFlow/SetAmountScreen/SendFlowSelectFeeRateView.swift index 8247be3ff..a6b7a3f13 100644 --- a/ios/Cove/Flows/SendFlow/SetAmountScreen/SendFlowSelectFeeRateView.swift +++ b/ios/Cove/Flows/SendFlow/SetAmountScreen/SendFlowSelectFeeRateView.swift @@ -5,7 +5,6 @@ // Created by Praveen Perera on 10/30/24. // -import Foundation import SwiftUI struct SendFlowSelectFeeRateView: View { @@ -118,10 +117,6 @@ private struct FeeOptionView: View { feeOption.totalFee()?.satsString() } - var satsPerVbyte: Double { - Double(feeOption.satPerVb()) - } - private var fiatAmount: String? { guard let totalFee = feeOption.totalFee() else { return nil } @@ -148,7 +143,7 @@ private struct FeeOptionView: View { } HStack { - Text("\(String(format: "%.2f", satsPerVbyte)) sats/vbyte") + Text(feeOption.satsPerVbyteString()) .font(.subheadline) .foregroundColor(fontColor) } diff --git a/ios/Cove/Flows/SettingsFlow/AboutAlerts.swift b/ios/Cove/Flows/SettingsFlow/AboutAlerts.swift new file mode 100644 index 000000000..0f289304b --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/AboutAlerts.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct AboutPresentationContext { + let alertState: Binding?> + let isBetaEnabled: Binding + let dismiss: () -> Void + let wipeCloudBackup: @Sendable () -> WipeCloudResult + + func dismissAlert() { + alertState.wrappedValue = nil + } + + func presentAlert(_ alert: AboutAlertState) { + alertState.wrappedValue = TaggedItem(alert) + } +} + +extension AboutAlertState: TaggedAlertPresentable { + func alert(context: AboutPresentationContext) -> AnyAlertBuilder { + switch self { + case .confirmBetaEnable: + AlertBuilder( + title: "Enable Beta Features?", + message: "This will enable experimental features", + actions: { + Button("Enable") { + do { + try Database().globalFlag().set( + key: .betaFeaturesEnabled, + value: true + ) + context.isBetaEnabled.wrappedValue = true + } catch { + context.presentAlert( + .betaError( + "Failed to enable beta features: \(error.localizedDescription)" + ) + ) + return + } + context.presentAlert(.betaEnabled) + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .confirmBetaDisable: + AlertBuilder( + title: "Disable Beta Features?", + message: "This will hide experimental features", + actions: { + Button("Disable") { + do { + try Database().globalFlag().set( + key: .betaFeaturesEnabled, + value: false + ) + context.isBetaEnabled.wrappedValue = false + } catch { + context.presentAlert( + .betaError( + "Failed to disable beta features: \(error.localizedDescription)" + ) + ) + return + } + context.dismissAlert() + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .betaEnabled: + AlertBuilder( + title: "Beta Features Enabled", + message: "Beta features have been enabled", + actions: { Button("OK") { context.dismiss() } } + ).eraseToAny() + + case let .betaError(error): + AlertBuilder( + title: "Something went wrong!", + message: error, + actions: { Button("OK") { context.dismissAlert() } } + ).eraseToAny() + + case .confirmWipeCloud: + AlertBuilder( + title: "Wipe Cloud Backup?", + message: "Deletes all iCloud backup files and resets local backup state", + actions: { + Button("Wipe", role: .destructive) { + Task.detached { + let result = context.wipeCloudBackup() + await MainActor.run { + context.presentAlert(.wipeCloudResult(result)) + } + } + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case let .wipeCloudResult(result): + AlertBuilder( + title: result.succeeded ? "Cloud Backup Wiped" : "Cloud Backup Wipe Failed", + message: result.message, + actions: { Button("OK") { context.dismissAlert() } } + ).eraseToAny() + + case .confirmResetLocalState: + AlertBuilder( + title: "Reset Local Backup State?", + message: "Clears local keychain and DB backup state but keeps iCloud files intact. Use this to test the recovery flow.", + actions: { + Button("Reset", role: .destructive) { + RustCloudBackupManager().debugResetCloudBackupState() + context.presentAlert( + .resetLocalStateResult( + "Local backup state reset. iCloud files are untouched." + ) + ) + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case let .resetLocalStateResult(message): + AlertBuilder( + title: "Local State Reset", + message: message, + actions: { Button("OK") { context.dismissAlert() } } + ).eraseToAny() + } + } +} diff --git a/ios/Cove/Flows/SettingsFlow/AboutScreen.swift b/ios/Cove/Flows/SettingsFlow/AboutScreen.swift index 5491dcb73..6f6aba012 100644 --- a/ios/Cove/Flows/SettingsFlow/AboutScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/AboutScreen.swift @@ -1,6 +1,6 @@ import SwiftUI -private enum AlertState: Equatable { +enum AboutAlertState: Equatable { case confirmBetaEnable case confirmBetaDisable case betaEnabled @@ -11,7 +11,7 @@ private enum AlertState: Equatable { case resetLocalStateResult(String) } -private struct WipeCloudResult: Equatable { +struct WipeCloudResult: Equatable { let succeeded: Bool let message: String } @@ -23,7 +23,7 @@ struct AboutScreen: View { @State private var buildTapCount = 0 @State private var buildTapTimer: Timer? = nil @State private var isBetaEnabled = Database().globalFlag().getBoolConfig(key: .betaFeaturesEnabled) - @State private var alertState: TaggedItem? = nil + @State private var alertState: TaggedItem? = nil private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" @@ -33,6 +33,15 @@ struct AboutScreen: View { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" } + private var presentationContext: AboutPresentationContext { + AboutPresentationContext( + alertState: $alertState, + isBetaEnabled: $isBetaEnabled, + dismiss: { dismiss() }, + wipeCloudBackup: Self.debugWipeCloudBackup + ) + } + var body: some View { Form { Section { @@ -116,127 +125,7 @@ struct AboutScreen: View { } .navigationTitle("About") .onDisappear { buildTapTimer?.invalidate(); buildTapTimer = nil } - .alert( - alertTitle, - isPresented: showingAlert, - presenting: alertState, - actions: { MyAlert($0).actions }, - message: { MyAlert($0).message } - ) - } - - // MARK: Alerts - - private var showingAlert: Binding { - Binding( - get: { alertState != nil }, - set: { if !$0 { alertState = .none } } - ) - } - - private var alertTitle: String { - guard let alertState else { return "Error" } - return MyAlert(alertState).title - } - - private func MyAlert(_ alert: TaggedItem) -> AnyAlertBuilder { - switch alert.item { - case .confirmBetaEnable: - AlertBuilder( - title: "Enable Beta Features?", - message: "This will enable experimental features", - actions: { - Button("Enable") { - do { - try Database().globalFlag().set(key: .betaFeaturesEnabled, value: true) - isBetaEnabled = true - } catch { - alertState = .init(.betaError("Failed to enable beta features: \(error.localizedDescription)")) - return - } - alertState = .init(.betaEnabled) - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .confirmBetaDisable: - AlertBuilder( - title: "Disable Beta Features?", - message: "This will hide experimental features", - actions: { - Button("Disable") { - do { - try Database().globalFlag().set(key: .betaFeaturesEnabled, value: false) - isBetaEnabled = false - } catch { - alertState = .init(.betaError("Failed to disable beta features: \(error.localizedDescription)")) - return - } - alertState = .none - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .betaEnabled: - AlertBuilder( - title: "Beta Features Enabled", - message: "Beta features have been enabled", - actions: { Button("OK") { dismiss() } } - ).eraseToAny() - - case let .betaError(error): - AlertBuilder( - title: "Something went wrong!", - message: error, - actions: { Button("OK") { alertState = .none } } - ).eraseToAny() - - case .confirmWipeCloud: - AlertBuilder( - title: "Wipe Cloud Backup?", - message: "Deletes all iCloud backup files and resets local backup state", - actions: { - Button("Wipe", role: .destructive) { - Task.detached { - let result = Self.debugWipeCloudBackup() - await MainActor.run { - alertState = .init(.wipeCloudResult(result)) - } - } - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case let .wipeCloudResult(result): - AlertBuilder( - title: result.succeeded ? "Cloud Backup Wiped" : "Cloud Backup Wipe Failed", - message: result.message, - actions: { Button("OK") { alertState = .none } } - ).eraseToAny() - - case .confirmResetLocalState: - AlertBuilder( - title: "Reset Local Backup State?", - message: "Clears local keychain and DB backup state but keeps iCloud files intact. Use this to test the recovery flow.", - actions: { - Button("Reset", role: .destructive) { - RustCloudBackupManager().debugResetCloudBackupState() - alertState = .init(.resetLocalStateResult("Local backup state reset. iCloud files are untouched.")) - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case let .resetLocalStateResult(message): - AlertBuilder( - title: "Local State Reset", - message: message, - actions: { Button("OK") { alertState = .none } } - ).eraseToAny() - } + .presentingAlert($alertState, context: presentationContext, defaultTitle: "Error") } private nonisolated static func debugWipeCloudBackup() -> WipeCloudResult { diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift index e7f5f7840..73a3ad5ff 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift @@ -131,8 +131,8 @@ struct VerificationSection: View { private func failureSection(_ failure: DeepVerificationFailure) -> some View { Section { switch failure { - case let .retry(message, _, retryContext): - retryFailureContent(message, retryContext: retryContext) + case let .retry(message, _, retryAction): + retryFailureContent(message, retryAction: retryAction) case let .recreateManifest(message, warning, _): recreateManifestContent(message: message, warning: warning) case let .reinitializeBackup(message, warning, _): @@ -152,11 +152,11 @@ struct VerificationSection: View { } @ViewBuilder - private func retryFailureContent(_ message: String, retryContext: CloudBackupRetryContext?) -> some View { + private func retryFailureContent(_ message: String, retryAction: CloudBackupRetryAction?) -> some View { Label(message, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(Color.statusWarning) - retryButton(retryContext: retryContext) + retryButton(retryAction: retryAction) CloudBackupRepairPasskeyButton(manager: manager, isBusy: isBusy) } @@ -229,9 +229,9 @@ struct VerificationSection: View { .disabled(isBusy) } - private func retryButton(retryContext: CloudBackupRetryContext?) -> some View { + private func retryButton(retryAction: CloudBackupRetryAction?) -> some View { Button { - if retryContext?.action == .verifyDiscoverable { + if retryAction == .verifyDiscoverable { manager.dispatch(action: .startVerificationDiscoverable(.cloudBackupDetail)) } else { manager.startVerification(source: .cloudBackupDetail) diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsAlerts.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsAlerts.swift new file mode 100644 index 000000000..8f9239d2e --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsAlerts.swift @@ -0,0 +1,165 @@ +import SwiftUI + +extension MainSettingsAlertState: TaggedAlertPresentable { + func alert(context: MainSettingsPresentationContext) -> AnyAlertBuilder { + switch self { + case let .unverifiedWallets(walletId): + AlertBuilder( + title: "Can't Enable Wipe Data PIN", + message: """ + You have wallets that have not been backed up. Please back up your wallets before enabling the Wipe Data PIN.\ + If you wipe the data without having a back up of your wallet, you will lose the bitcoin in that wallet. + """, + actions: { + Button("Go To Wallet") { + try? context.app.selectWalletOrThrow(walletId) + } + + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .confirmEnableWipeMePin: + AlertBuilder( + title: "Are you sure?", + message: + """ + + Enabling the Wipe Data PIN will let you chose a PIN that if entered will wipe all Cove wallet data on this device. + + If you wipe the data without having a back up of your wallet, you will lose the bitcoin in that wallet.\u{20} + + Please make sure you have a backup of your wallet before enabling this. + """, + actions: { + Button("Yes, Enable Wipe Data PIN") { + context.dismissAlert() + context.presentSheet(.enableWipeDataPin) + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .confirmDecoyPin: + AlertBuilder( + title: "Are you sure?", + message: + """ + + Enabling Decoy PIN will let you chose a PIN that if entered, will show you a different set of wallets. + + These wallets will only be accessible by entering the decoy PIN instead of your regular PIN. + + To access your regular wallets, you will have to close the app, start it again and enter your regular PIN. + """, + actions: { + Button("Yes, Enable Decoy PIN") { + context.dismissAlert() + context.presentSheet(.enableDecoyPin) + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .notePinRequired: + AlertBuilder( + title: "PIN is required", + message: "Setting a PIN is required to have a wipe data PIN", + actions: { Button("OK") { context.dismissAlert() } } + ).eraseToAny() + + case let .noteFaceIdDisabling(nextAlertState): + AlertBuilder( + title: "Disable FaceID Unlock?", + message: """ + + Enabling this trick PIN will disable FaceID unlock for Cove.\u{20} + + Going forward, you will have to use your PIN to unlock Cove. + """, + actions: { + Button("Disable FaceID", role: .destructive) { + context.auth.dispatch(action: .disableBiometric) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.350) { + context.presentAlert(nextAlertState) + } + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case .noteNoFaceIdWhenTrickPins: + AlertBuilder( + title: "Can't do that", + message: """ + + You can't have Decoy PIN & Wipe Data Pin enabled and FaceID active at the same time. + + Do you wan't to disable both of these trick PINs and enable FaceID? + """, + actions: { + Button("Cancel", role: .cancel) { context.dismissAlert() } + Button("Yes, Disable trick PINs", role: .destructive) { + context.presentSheet(.removeAllTrickPins) + } + } + ).eraseToAny() + + case .noteNoFaceIdWhenWipeMePin: + AlertBuilder( + title: "Can't do that", + message: "You can't have both Wipe Data PIN and FaceID active at the same time", + actions: { + Button("Cancel", role: .cancel) { context.dismissAlert() } + Button("Disable Wipe Data PIN", role: .destructive) { + if !context.auth.isDecoyPinEnabled { + let nextSheetState = TaggedItem(MainSettingsSheetState.enableBiometric) + context.presentSheet(.removeWipeDataPin(nextSheetState)) + } else { + context.presentSheet(.removeWipeDataPin(.none)) + } + } + } + ).eraseToAny() + + case .noteNoFaceIdWhenDecoyPin: + AlertBuilder( + title: "Can't do that", + message: "You can't have both Decoy PIN and FaceID active at the same time", + actions: { + Button("Cancel", role: .cancel) { context.dismissAlert() } + Button("Disable Decoy Pin", role: .destructive) { + if !context.auth.isWipeDataPinEnabled { + let nextSheetState = TaggedItem(MainSettingsSheetState.enableBiometric) + context.presentSheet(.removeDecoyPin(nextSheetState)) + } else { + context.presentSheet(.removeDecoyPin(.none)) + } + } + } + ).eraseToAny() + + case .confirmBetaImportExport: + AlertBuilder( + title: "Experimental Feature", + message: "This is a very experimental feature. Use with caution. This is mostly used by developers for testing purposes.", + actions: { + Button("Accept") { + try? Database().globalFlag().set(key: .betaImportExportEnabled, value: true) + context.isBetaImportExportEnabled.wrappedValue = true + context.dismissAlert() + } + Button("Cancel", role: .cancel) { context.dismissAlert() } + } + ).eraseToAny() + + case let .extraSetPinError(error): + AlertBuilder( + title: "Something went wrong!", + message: error, + actions: { Button("OK") { context.dismissAlert() } } + ) + .eraseToAny() + } + } +} diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationContext.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationContext.swift new file mode 100644 index 000000000..d6c13337f --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationContext.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct MainSettingsPresentationContext { + let app: AppManager + let auth: AuthManager + let sheetState: Binding?> + let alertState: Binding?> + let isPinEnabled: Binding + let isBetaImportExportEnabled: Binding + let canUseBiometrics: () -> Bool + let setPin: (String) -> Void + let setWipeDataPin: (String) -> Void + let setDecoyPin: (String) -> Void + + func dismissAlert() { + alertState.wrappedValue = .none + } + + func dismissSheet() { + sheetState.wrappedValue = .none + } + + func presentAlert(_ alert: MainSettingsAlertState) { + alertState.wrappedValue = .init(alert) + } + + func presentSheet(_ sheet: MainSettingsSheetState) { + sheetState.wrappedValue = .init(sheet) + } + + func setSheet(_ sheet: TaggedItem?) { + sheetState.wrappedValue = sheet + } +} diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationState.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationState.swift new file mode 100644 index 000000000..3f61eb328 --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsPresentationState.swift @@ -0,0 +1,31 @@ +enum MainSettingsSheetState: Equatable { + case newPin + case removePin + case removeAllTrickPins + indirect case removeWipeDataPin(TaggedItem? = .none) + indirect case removeDecoyPin(TaggedItem? = .none) + case changePin + case disableBiometric + case enableAuth + case enableBiometric + case enableWipeDataPin + case enableDecoyPin + case backupExport + case backupImport + case backupVerify + case backupExportAuth + case cloudBackupOnboarding +} + +enum MainSettingsAlertState: Equatable { + case unverifiedWallets(WalletId) + case confirmEnableWipeMePin + case confirmDecoyPin + case noteNoFaceIdWhenTrickPins + case noteNoFaceIdWhenWipeMePin + case noteNoFaceIdWhenDecoyPin + case notePinRequired + indirect case noteFaceIdDisabling(MainSettingsAlertState) + case confirmBetaImportExport + case extraSetPinError(String) +} diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift index 3ce312fd0..fca2c25ae 100644 --- a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift @@ -1,38 +1,6 @@ import LocalAuthentication import SwiftUI -private enum SheetState: Equatable { - case newPin - case removePin - case removeAllTrickPins - indirect case removeWipeDataPin(TaggedItem? = .none) - indirect case removeDecoyPin(TaggedItem? = .none) - case changePin - case disableBiometric - case enableAuth - case enableBiometric - case enableWipeDataPin - case enableDecoyPin - case backupExport - case backupImport - case backupVerify - case backupExportAuth - case cloudBackupOnboarding -} - -private enum AlertState: Equatable { - case unverifiedWallets(WalletId) - case confirmEnableWipeMePin - case confirmDecoyPin - case noteNoFaceIdWhenTrickPins - case noteNoFaceIdWhenWipeMePin - case noteNoFaceIdWhenDecoyPin - case notePinRequired - indirect case noteFaceIdDisabling(AlertState) - case confirmBetaImportExport - case extraSetPinError(String) -} - struct MainSettingsScreen: View { @Environment(AppManager.self) private var app @Environment(AuthManager.self) private var auth @@ -42,8 +10,8 @@ struct MainSettingsScreen: View { @Environment(\.colorScheme) private var colorScheme // private - @State private var sheetState: TaggedItem? = nil - @State private var alertState: TaggedItem? = nil + @State private var sheetState: TaggedItem? = nil + @State private var alertState: TaggedItem? = nil // settings toggles for when you are in decoy mode @State private var isPinEnabled: Bool = true @@ -235,6 +203,21 @@ struct MainSettingsScreen: View { } } + private var presentationContext: MainSettingsPresentationContext { + MainSettingsPresentationContext( + app: app, + auth: auth, + sheetState: $sheetState, + alertState: $alertState, + isPinEnabled: $isPinEnabled, + isBetaImportExportEnabled: $isBetaImportExportEnabled, + canUseBiometrics: canUseBiometrics, + setPin: setPin, + setWipeDataPin: setWipeDataPin, + setDecoyPin: setDecoyPin + ) + } + var body: some View { Form { MainSettingsGeneralSection() @@ -286,433 +269,8 @@ struct MainSettingsScreen: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) - .fullScreenCover(item: $sheetState, content: SheetContent) - .alert( - alertTitle, - isPresented: showingAlert, - presenting: alertState, - actions: { MyAlert($0).actions }, - message: { MyAlert($0).message } - ) - } - - private func CancelView(_ content: () -> some View) -> some View { - VStack { - HStack { - Spacer() - - Button("Cancel") { - sheetState = .none - } - .foregroundStyle(.white) - .font(.headline) - } - .padding() - - content() - } - .background(.midnightBlue) - } - - // MARK: Alerts - - private var showingAlert: Binding { - Binding( - get: { alertState != nil }, - set: { if !$0 { alertState = .none } } - ) - } - - private var alertTitle: String { - guard let alertState else { return "Error" } - return MyAlert(alertState).title - } - - private func MyAlert(_ alert: TaggedItem) -> AnyAlertBuilder { - switch alert.item { - case let .unverifiedWallets(walletId): - AlertBuilder( - title: "Can't Enable Wipe Data PIN", - message: """ - You have wallets that have not been backed up. Please back up your wallets before enabling the Wipe Data PIN.\ - If you wipe the data without having a back up of your wallet, you will lose the bitcoin in that wallet. - """, - actions: { - Button("Go To Wallet") { - try? app.selectWalletOrThrow(walletId) - } - - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .confirmEnableWipeMePin: - AlertBuilder( - title: "Are you sure?", - message: - """ - - Enabling the Wipe Data PIN will let you chose a PIN that if entered will wipe all Cove wallet data on this device. - - If you wipe the data without having a back up of your wallet, you will lose the bitcoin in that wallet. - - Please make sure you have a backup of your wallet before enabling this. - """, - actions: { - Button("Yes, Enable Wipe Data PIN") { - alertState = .none - sheetState = .init(.enableWipeDataPin) - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .confirmDecoyPin: - AlertBuilder( - title: "Are you sure?", - message: - """ - - Enabling Decoy PIN will let you chose a PIN that if entered, will show you a different set of wallets. - - These wallets will only be accessible by entering the decoy PIN instead of your regular PIN. - - To access your regular wallets, you will have to close the app, start it again and enter your regular PIN. - """, - actions: { - Button("Yes, Enable Decoy PIN") { - alertState = .none - sheetState = .init(.enableDecoyPin) - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .notePinRequired: - AlertBuilder( - title: "PIN is required", - message: "Setting a PIN is required to have a wipe data PIN", - actions: { Button("OK") { alertState = .none } } - ).eraseToAny() - - case let .noteFaceIdDisabling(nextAlertState): - AlertBuilder( - title: "Disable FaceID Unlock?", - message: """ - - Enabling this trick PIN will disable FaceID unlock for Cove. - - Going forward, you will have to use your PIN to unlock Cove. - """, - actions: { - Button("Disable FaceID", role: .destructive) { - auth.dispatch(action: .disableBiometric) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.350) { - alertState = .init(nextAlertState) - } - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case .noteNoFaceIdWhenTrickPins: - AlertBuilder( - title: "Can't do that", - message: """ - - You can't have Decoy PIN & Wipe Data Pin enabled and FaceID active at the same time. - - Do you wan't to disable both of these trick PINs and enable FaceID? - """, - actions: { - Button("Cancel", role: .cancel) { alertState = .none } - Button("Yes, Disable trick PINs", role: .destructive) { - sheetState = .init(.removeAllTrickPins) - } - } - ).eraseToAny() - - case .noteNoFaceIdWhenWipeMePin: - AlertBuilder( - title: "Can't do that", - message: "You can't have both Wipe Data PIN and FaceID active at the same time", - actions: { - Button("Cancel", role: .cancel) { alertState = .none } - Button("Disable Wipe Data PIN", role: .destructive) { - if !auth.isDecoyPinEnabled { - let nextSheetState = TaggedItem(SheetState.enableBiometric) - sheetState = .init(.removeWipeDataPin(nextSheetState)) - } else { - sheetState = .init(.removeWipeDataPin(.none)) - } - } - } - ).eraseToAny() - - case .noteNoFaceIdWhenDecoyPin: - AlertBuilder( - title: "Can't do that", - message: "You can't have both Decoy PIN and FaceID active at the same time", - actions: { - Button("Cancel", role: .cancel) { alertState = .none } - Button("Disable Decoy Pin", role: .destructive) { - if !auth.isWipeDataPinEnabled { - let nextSheetState = TaggedItem(SheetState.enableBiometric) - sheetState = .init(.removeDecoyPin(nextSheetState)) - } else { - sheetState = .init(.removeDecoyPin(.none)) - } - } - } - ).eraseToAny() - - case .confirmBetaImportExport: - AlertBuilder( - title: "Experimental Feature", - message: "This is a very experimental feature. Use with caution. This is mostly used by developers for testing purposes.", - actions: { - Button("Accept") { - try? Database().globalFlag().set(key: .betaImportExportEnabled, value: true) - isBetaImportExportEnabled = true - alertState = .none - } - Button("Cancel", role: .cancel) { alertState = .none } - } - ).eraseToAny() - - case let .extraSetPinError(error): - AlertBuilder( - title: "Something went wrong!", - message: error, - actions: { Button("OK") { alertState = .none } } - ) - .eraseToAny() - } - } - - // MARK: Sheets - - @ViewBuilder - private func SheetContent(_ state: TaggedItem) -> some View { - switch state.item { - case .enableAuth: - if canUseBiometrics() { - LockView( - lockType: .biometric, - isPinCorrect: { _ in true }, - onUnlock: { pin in - if auth.isInDecoyMode() { return } - auth.dispatch(action: .enableBiometric) - if !pin.isEmpty { auth.dispatch(action: .setPin(pin)) } - - sheetState = .none - }, - backAction: { sheetState = .none }, - content: { EmptyView() } - ) - } else { - NewPinView(onComplete: setPin, backAction: { sheetState = .none }) - } - - case .newPin: - NewPinView(onComplete: setPin, backAction: { sheetState = .none }) - - case .removePin: - NumberPadPinView( - title: "Enter Current PIN", - isPinCorrect: { pin in - if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } - return auth.checkPin(pin) - }, - - showPin: false, - backAction: { sheetState = .none }, - onUnlock: { _ in - if auth.isInDecoyMode() { - sheetState = .none - isPinEnabled = false - return - } - - auth.dispatch(action: .disablePin) - auth.dispatch(action: .disableWipeDataPin) - sheetState = .none - } - ) - - case let .removeWipeDataPin(nextSheet): - NumberPadPinView( - title: "Enter Current PIN", - isPinCorrect: auth.checkPin, - showPin: false, - backAction: { sheetState = .none }, - onUnlock: { _ in - if auth.isInDecoyMode() { return } - auth.dispatch(action: .disableWipeDataPin) - sheetState = nextSheet - } - ) - - case let .removeDecoyPin(nextState): - NumberPadPinView( - title: "Enter Current PIN", - isPinCorrect: auth.checkPin, - showPin: false, - backAction: { sheetState = .none }, - onUnlock: { _ in - auth.dispatch(action: .disableDecoyPin) - sheetState = nextState - } - ) - - case .removeAllTrickPins: - NumberPadPinView( - title: "Enter Current PIN", - isPinCorrect: auth.checkPin, - showPin: false, - backAction: { sheetState = .none }, - onUnlock: { _ in - auth.dispatch(action: .disableDecoyPin) - auth.dispatch(action: .disableWipeDataPin) - sheetState = .init(.enableBiometric) - } - ) - - case .changePin: - ChangePinView( - isPinCorrect: { pin in - if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } - return auth.checkPin(pin) - }, - backAction: { sheetState = .none }, - onComplete: { pin in - if auth.isInDecoyMode() { - sheetState = .none - return - } - - sheetState = .none - if auth.checkWipeDataPin(pin) { - alertState = .init( - .extraSetPinError( - "Can't update PIN because its the same as your wipe data PIN" - ) - ) - return - } - - setPin(pin) - } - ) - - case .disableBiometric: - LockView( - lockType: auth.type, - isPinCorrect: auth.checkPin, - onUnlock: { _ in - auth.dispatch(action: .disableBiometric) - sheetState = .none - }, - backAction: { sheetState = .none }, - content: { EmptyView() } - ) - - case .enableBiometric: - LockView( - lockType: .biometric, - isPinCorrect: { _ in true }, - onUnlock: { _ in - auth.dispatch(action: .enableBiometric) - sheetState = .none - }, - backAction: { sheetState = .none }, - content: { EmptyView() } - ) - - case .enableWipeDataPin: - WipeDataPinView( - onComplete: setWipeDataPin, - backAction: { - sheetState = .none - } - ) - - case .enableDecoyPin: - DecoyPinView( - onComplete: setDecoyPin, - backAction: { - sheetState = .none - } - ) - - case .backupExportAuth: - LockView( - lockType: auth.type, - isPinCorrect: { pin in - if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } - return auth.checkPin(pin) - }, - onUnlock: { _ in - if auth.isInDecoyMode() { sheetState = .none; return } - sheetState = .init(.backupExport) - }, - backAction: { sheetState = .none }, - content: { EmptyView() } - ) - - case .backupExport: - NavigationStack { - BackupExportView() - .navigationTitle("Export Backup") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { sheetState = .none } - } - } - } - - case .backupImport: - NavigationStack { - BackupImportView() - .navigationTitle("Import Backup") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { sheetState = .none } - } - } - } - - case .backupVerify: - NavigationStack { - BackupVerifyView() - .navigationTitle("Verify Backup") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { sheetState = .none } - } - } - } - - case .cloudBackupOnboarding: - SettingsCloudBackupEnableSheet( - onComplete: { - sheetState = .none - DispatchQueue.main.async { - guard !app.currentRoute.isEqual(routeToCheck: .settings(.cloudBackup)) else { - return - } - - app.pushRoute(.settings(.cloudBackup)) - } - }, - onDismiss: { - sheetState = .none - } - ) - } + .presentingFullScreenCover($sheetState, context: presentationContext) + .presentingAlert($alertState, context: presentationContext, defaultTitle: "Error") } // MARK: Setter functions diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsSheets.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsSheets.swift new file mode 100644 index 000000000..fc2fcb135 --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsSheets.swift @@ -0,0 +1,242 @@ +import SwiftUI + +extension MainSettingsSheetState: TaggedSheetPresentable { + func sheet(context: MainSettingsPresentationContext) -> AnyView { + AnyView(MainSettingsSheetContent(sheet: self, context: context)) + } +} + +private struct MainSettingsSheetContent: View { + let sheet: MainSettingsSheetState + let context: MainSettingsPresentationContext + + private var auth: AuthManager { + context.auth + } + + var body: some View { + switch sheet { + case .enableAuth: + if context.canUseBiometrics() { + LockView( + lockType: .biometric, + isPinCorrect: { _ in true }, + onUnlock: { pin in + if auth.isInDecoyMode() { return } + auth.dispatch(action: .enableBiometric) + if !pin.isEmpty { auth.dispatch(action: .setPin(pin)) } + + context.dismissSheet() + }, + backAction: { context.dismissSheet() }, + content: { EmptyView() } + ) + } else { + NewPinView(onComplete: context.setPin, backAction: { context.dismissSheet() }) + } + + case .newPin: + NewPinView(onComplete: context.setPin, backAction: { context.dismissSheet() }) + + case .removePin: + NumberPadPinView( + title: "Enter Current PIN", + isPinCorrect: { pin in + if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } + return auth.checkPin(pin) + }, + + showPin: false, + backAction: { context.dismissSheet() }, + onUnlock: { _ in + if auth.isInDecoyMode() { + context.dismissSheet() + context.isPinEnabled.wrappedValue = false + return + } + + auth.dispatch(action: .disablePin) + auth.dispatch(action: .disableWipeDataPin) + context.dismissSheet() + } + ) + + case let .removeWipeDataPin(nextSheet): + NumberPadPinView( + title: "Enter Current PIN", + isPinCorrect: auth.checkPin, + showPin: false, + backAction: { context.dismissSheet() }, + onUnlock: { _ in + if auth.isInDecoyMode() { return } + auth.dispatch(action: .disableWipeDataPin) + context.setSheet(nextSheet) + } + ) + + case let .removeDecoyPin(nextState): + NumberPadPinView( + title: "Enter Current PIN", + isPinCorrect: auth.checkPin, + showPin: false, + backAction: { context.dismissSheet() }, + onUnlock: { _ in + auth.dispatch(action: .disableDecoyPin) + context.setSheet(nextState) + } + ) + + case .removeAllTrickPins: + NumberPadPinView( + title: "Enter Current PIN", + isPinCorrect: auth.checkPin, + showPin: false, + backAction: { context.dismissSheet() }, + onUnlock: { _ in + auth.dispatch(action: .disableDecoyPin) + auth.dispatch(action: .disableWipeDataPin) + context.presentSheet(.enableBiometric) + } + ) + + case .changePin: + ChangePinView( + isPinCorrect: { pin in + if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } + return auth.checkPin(pin) + }, + backAction: { context.dismissSheet() }, + onComplete: { pin in + if auth.isInDecoyMode() { + context.dismissSheet() + return + } + + context.dismissSheet() + if auth.checkWipeDataPin(pin) { + context.presentAlert( + .extraSetPinError( + "Can't update PIN because its the same as your wipe data PIN" + ) + ) + return + } + + context.setPin(pin) + } + ) + + case .disableBiometric: + LockView( + lockType: auth.type, + isPinCorrect: auth.checkPin, + onUnlock: { _ in + auth.dispatch(action: .disableBiometric) + context.dismissSheet() + }, + backAction: { context.dismissSheet() }, + content: { EmptyView() } + ) + + case .enableBiometric: + LockView( + lockType: .biometric, + isPinCorrect: { _ in true }, + onUnlock: { _ in + auth.dispatch(action: .enableBiometric) + context.dismissSheet() + }, + backAction: { context.dismissSheet() }, + content: { EmptyView() } + ) + + case .enableWipeDataPin: + WipeDataPinView( + onComplete: context.setWipeDataPin, + backAction: { + context.dismissSheet() + } + ) + + case .enableDecoyPin: + DecoyPinView( + onComplete: context.setDecoyPin, + backAction: { + context.dismissSheet() + } + ) + + case .backupExportAuth: + LockView( + lockType: auth.type, + isPinCorrect: { pin in + if auth.isInDecoyMode() { return auth.checkDecoyPin(pin) } + return auth.checkPin(pin) + }, + onUnlock: { _ in + if auth.isInDecoyMode() { + context.dismissSheet() + return + } + + context.presentSheet(.backupExport) + }, + backAction: { context.dismissSheet() }, + content: { EmptyView() } + ) + + case .backupExport: + NavigationStack { + BackupExportView() + .navigationTitle("Export Backup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { context.dismissSheet() } + } + } + } + + case .backupImport: + NavigationStack { + BackupImportView() + .navigationTitle("Import Backup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { context.dismissSheet() } + } + } + } + + case .backupVerify: + NavigationStack { + BackupVerifyView() + .navigationTitle("Verify Backup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { context.dismissSheet() } + } + } + } + + case .cloudBackupOnboarding: + SettingsCloudBackupEnableSheet( + onComplete: { + context.dismissSheet() + DispatchQueue.main.async { + guard !context.app.currentRoute.isEqual(routeToCheck: .settings(.cloudBackup)) else { + return + } + + context.app.pushRoute(.settings(.cloudBackup)) + } + }, + onDismiss: { + context.dismissSheet() + } + ) + } + } +} diff --git a/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsView.swift b/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsView.swift index c187ec207..ba68a7736 100644 --- a/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsView.swift +++ b/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsView.swift @@ -255,8 +255,8 @@ struct WalletSettingsView: View { private extension WalletBirthday { var displayValue: String { switch self { - case let .blockHeight(height): - "Block \(height.formatted(.number.grouping(.automatic)))" + case .blockHeight: + "Block \(blockHeightFmt() ?? "")" case let .timestamp(timestamp): Date(timeIntervalSince1970: TimeInterval(timestamp)) .formatted(date: .abbreviated, time: .omitted) diff --git a/ios/Cove/ImportWalletManager.swift b/ios/Cove/ImportWalletManager.swift index 9d2688a12..60632b926 100644 --- a/ios/Cove/ImportWalletManager.swift +++ b/ios/Cove/ImportWalletManager.swift @@ -1,28 +1,9 @@ import SwiftUI -@Observable class ImportWalletManager: ImportWalletManagerReconciler { - private let logger = Log(id: "ImportWalletManager") +@Observable class ImportWalletManager { var rust: RustImportWalletManager public init() { rust = RustImportWalletManager() - rust.listenForUpdates(reconciler: self) - } - - func reconcile(message: ImportWalletManagerReconcileMessage) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("Reconcile: \(message)") - - switch message { - case .noOp: - break - } - } - } - - public func dispatch(action: ImportWalletManagerAction) { - logger.debug("Dispatch: \(action)") - rust.dispatch(action: action) } } diff --git a/ios/Cove/ManagerCache.swift b/ios/Cove/ManagerCache.swift new file mode 100644 index 000000000..7aea3cba8 --- /dev/null +++ b/ios/Cove/ManagerCache.swift @@ -0,0 +1,163 @@ +import Observation + +@Observable final class ManagerCache { + private let logger = Log(id: "ManagerCache") + + @ObservationIgnored + private let backgroundScanTaskHandler: BackgroundScanTaskHandler + + private(set) var walletManager: WalletManager? + private(set) var sendFlowManager: SendFlowManager? + @ObservationIgnored + weak var coinControlManager: CoinControlManager? + + init(backgroundScanTaskHandler: BackgroundScanTaskHandler) { + self.backgroundScanTaskHandler = backgroundScanTaskHandler + } + + func cachedWalletManager(id: WalletId) -> WalletManager? { + guard let walletManager, walletManager.id == id else { return nil } + return walletManager + } + + func walletMetadata(id: WalletId, wallets: [WalletMetadata]) -> WalletMetadata? { + if let walletManager = cachedWalletManager(id: id) { + return walletManager.walletMetadata + } + + return wallets.first(where: { $0.id == id }) + } + + func ensureWalletManager( + id: WalletId, + delegate: WalletManagerDelegate + ) throws -> WalletManager { + if let walletManager = cachedWalletManager(id: id) { + logger.debug("found and using vm for \(id)") + return walletManager + } + + logger.debug( + "did not find vm for \(id), creating new vm: \(walletManager?.id ?? "none")" + ) + clearWalletManager() + + let walletManager = try WalletManager(id: id, delegate: delegate) + backgroundScanTaskHandler.observeInitialScanLifecycle(for: walletManager) { [weak self] in + self?.walletManager + } + self.walletManager = walletManager + return walletManager + } + + func clearWalletManager(id: WalletId? = nil) { + if id == nil { + backgroundScanTaskHandler.endInitialScanBackgroundTask() + walletManager?.setInitialScanLifecycleChanged(nil) + walletManager?.close() + walletManager = nil + clearSendFlowManager() + return + } + + if walletManager?.id == id { + backgroundScanTaskHandler.endInitialScanBackgroundTask() + walletManager?.setInitialScanLifecycleChanged(nil) + walletManager?.close() + walletManager = nil + } + + clearSendFlowManager(id: id) + } + + func cachedSendFlowManager(id: WalletId) -> SendFlowManager? { + guard let sendFlowManager, sendFlowManager.id == id else { return nil } + return sendFlowManager + } + + func ensureSendFlowManager( + _ walletManager: WalletManager, + presenter: SendFlowPresenter + ) throws -> SendFlowManager { + if let sendFlowManager = cachedSendFlowManager(id: walletManager.id) { + logger.debug("found and using sendflow manager for \(walletManager.id)") + sendFlowManager.presenter = presenter + return sendFlowManager + } + + logger.debug("did not find SendFlowManager for \(walletManager.id), creating new") + clearSendFlowManager() + + let sendFlowManager = try SendFlowManager( + walletManager.rust.newSendFlowManager(balance: walletManager.balance), + presenter: presenter + ) + self.sendFlowManager = sendFlowManager + return sendFlowManager + } + + public func setCoinControlManager(_ manager: CoinControlManager) { + coinControlManager = manager + } + + public func clearCoinControlManager(_ manager: CoinControlManager) { + if coinControlManager === manager { + coinControlManager = nil + } + } + + func clearCoinControlManager() { + guard let coinControlManager else { return } + + self.coinControlManager = nil + coinControlManager.close() + } + + func reconcileCoinControlManagerOwnership(router: Router) { + guard coinControlManager != nil else { return } + guard !router.containsCoinControlRoute else { return } + + clearCoinControlManager() + } + + @MainActor + public func reconcileAfterLabelsChanged(walletId: WalletId) { + if let walletManager, walletManager.id == walletId { + walletManager.reconcileAfterLabelsChanged() + } + + if let coinControlManager, coinControlManager.id == walletId { + Task { await coinControlManager.reloadLabels() } + } + + if let sendFlowManager, sendFlowManager.id == walletId { + sendFlowManager.reconcileAfterLabelsChanged() + } + } + + func clearSendFlowManager(id: WalletId? = nil) { + guard id == nil || sendFlowManager?.id == id else { return } + sendFlowManager = nil + } + + func beginInitialScanBackgroundTaskIfNeeded() { + backgroundScanTaskHandler.beginInitialScanBackgroundTaskIfNeeded(walletManager: walletManager) + } + + func endInitialScanBackgroundTask() { + backgroundScanTaskHandler.endInitialScanBackgroundTask() + } +} + +extension Router { + var containsCoinControlRoute: Bool { + self.default.isCoinControlRoute || routes.contains { $0.isCoinControlRoute } + } +} + +private extension Route { + var isCoinControlRoute: Bool { + if case .coinControl = self { return true } + return false + } +} diff --git a/ios/Cove/NavigationCoordinator.swift b/ios/Cove/NavigationCoordinator.swift new file mode 100644 index 000000000..0dd8aa0d7 --- /dev/null +++ b/ios/Cove/NavigationCoordinator.swift @@ -0,0 +1,340 @@ +import Observation +import SwiftUI + +private let sidebarNavigationDelayMs = 250 +private let navigationSettleDelayMs = 800 + +protocol NavigationRouteClient: AnyObject { + func dispatch(action: AppAction) throws + func loadAndResetDefaultRoute(route: Route) + func resetAfterLoading(to routes: [Route]) + func resetDefaultRouteTo(route: Route) + func resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: [Route]) +} + +extension FfiApp: NavigationRouteClient {} + +@Observable final class NavigationCoordinator { + typealias Sleep = @MainActor (Duration) async throws -> Void + typealias RouteOwnershipReconciler = (Router) -> Void + + @ObservationIgnored + private let routeClient: NavigationRouteClient + @ObservationIgnored + private let navigationGenerations: GenerationTrackerProtocol + @ObservationIgnored + private let navigationSettleDelay: Duration + @ObservationIgnored + private let sidebarNavigationDelay: Duration + @ObservationIgnored + private let sleep: Sleep + @ObservationIgnored + private var pendingSidebarNavigationTask: Task? + @ObservationIgnored + private var navigationSettleTask: Task? + + var isNavigationSettled = true + + init( + routeClient: NavigationRouteClient, + navigationGenerations: GenerationTrackerProtocol = GenerationTracker(), + navigationSettleDelay: Duration = .milliseconds(navigationSettleDelayMs), + sidebarNavigationDelay: Duration = .milliseconds(sidebarNavigationDelayMs), + sleep: @escaping Sleep = { try await Task.sleep(for: $0) } + ) { + self.routeClient = routeClient + self.navigationGenerations = navigationGenerations + self.navigationSettleDelay = navigationSettleDelay + self.sidebarNavigationDelay = sidebarNavigationDelay + self.sleep = sleep + } + + deinit { + pendingSidebarNavigationTask?.cancel() + navigationSettleTask?.cancel() + } + + func reset() { + pendingSidebarNavigationTask?.cancel() + pendingSidebarNavigationTask = nil + navigationSettleTask?.cancel() + navigationSettleTask = nil + advanceNavigationGeneration() + } + + func selectWallet( + _ id: WalletId, + router _: Router, + isSidebarVisible: inout Bool, + advancesGeneration: Bool = true + ) throws { + if advancesGeneration { + advanceNavigationGeneration() + } + + try routeClient.dispatch(action: .selectWallet(id: id)) + isSidebarVisible = false + } + + func selectLatestOrNewWallet(isSidebarVisible: inout Bool) throws { + advanceNavigationGeneration() + try routeClient.dispatch(action: .selectLatestOrNewWallet) + isSidebarVisible = false + } + + func pushRoute( + _ route: Route, + router: inout Router, + isSidebarVisible: inout Bool, + advancesGeneration: Bool = true, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + if advancesGeneration, isDuplicateTopRoute(route, router: router) { + isSidebarVisible = false + return + } + + if advancesGeneration { + advanceNavigationGeneration() + } + + isSidebarVisible = false + guard !isDuplicateTopRoute(route, router: router) else { return } + + router.routes.append(route) + reconcileRouteOwnership(router) + } + + func pushRoutes( + _ routes: [Route], + router: inout Router, + isSidebarVisible: inout Bool, + advancesGeneration: Bool = true, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + if advancesGeneration { + advanceNavigationGeneration() + } + + isSidebarVisible = false + router.routes.append(contentsOf: routes) + reconcileRouteOwnership(router) + } + + func popRoute( + router: inout Router, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + advanceNavigationGeneration() + + if !router.routes.isEmpty { + router.routes.removeLast() + reconcileRouteOwnership(router) + } + } + + func setRoute( + _ routes: [Route], + router: inout Router, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + advanceNavigationGeneration() + router.routes = routes + reconcileRouteOwnership(router) + } + + func scanNfc( + advancesGeneration: Bool = true, + scan: () -> Void + ) { + if advancesGeneration { + advanceNavigationGeneration() + } + + scan() + } + + @MainActor + func resetRoute(to routes: [Route], advancesGeneration: Bool = true) { + if advancesGeneration { + advanceNavigationGeneration() + } + + if routes.count > 1 { + routeClient.resetNestedRoutesTo(defaultRoute: routes[0], nestedRoutes: Array(routes[1...])) + } else if let route = routes.first { + routeClient.resetDefaultRouteTo(route: route) + } + } + + func resetRoute(to route: Route, advancesGeneration: Bool = true) { + if advancesGeneration { + advanceNavigationGeneration() + } + + routeClient.resetDefaultRouteTo(route: route) + } + + @MainActor + func loadAndReset(to route: Route) { + advanceNavigationGeneration() + routeClient.loadAndResetDefaultRoute(route: route) + } + + func closeSidebarThenNavigate( + isSidebarVisible: inout Bool, + action: @escaping @MainActor () -> Void + ) { + pendingSidebarNavigationTask?.cancel() + let generation = advanceNavigationGeneration() + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isSidebarVisible = false + } + + pendingSidebarNavigationTask = Task { @MainActor [weak self] in + guard let self else { return } + + do { + try await sleep(sidebarNavigationDelay) + } catch { + return + } + + guard isNavigationGenerationCurrent(generation) else { return } + action() + } + } + + @MainActor + func captureLoadAndResetGeneration() -> GenerationToken { + navigationGenerations.capture() + } + + @MainActor + func startLoadAndResetTargetPrewarm( + generation: GenerationToken, + routes: [Route], + prewarmSelectedWallet: @escaping @MainActor (WalletId) async -> Void + ) { + Task { [weak self] in + await self?.prewarmLoadAndResetTargetIfCurrent( + generation: generation, + routes: routes, + prewarmSelectedWallet: prewarmSelectedWallet + ) + } + } + + @MainActor + func resetAfterLoadingIfCurrent( + generation: GenerationToken, + route: Route, + nextRoute: [Route], + router: Router + ) { + guard isNavigationGenerationCurrent(generation) else { return } + guard router.default == route else { return } + + routeClient.resetAfterLoading(to: nextRoute) + } + + func applyRouteUpdated( + routes: [Route], + router: inout Router, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + let didChangeRoute = router.routes != routes + router.routes = routes + reconcileRouteOwnership(router) + + if didChangeRoute { + scheduleNavigationSettledForCurrentGeneration() + } + } + + func applyPushedRoute( + _ route: Route, + router: inout Router, + isSidebarVisible: inout Bool, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + guard !isDuplicateTopRoute(route, router: router) else { + isSidebarVisible = false + return + } + + router.routes.append(route) + reconcileRouteOwnership(router) + scheduleNavigationSettledForCurrentGeneration() + } + + func applyDefaultRouteChanged( + route: Route, + nestedRoutes: [Route], + router: inout Router, + routeId: inout UUID, + reconcileRouteOwnership: RouteOwnershipReconciler + ) { + router.routes = nestedRoutes + router.default = route + routeId = UUID() + reconcileRouteOwnership(router) + scheduleNavigationSettledForCurrentGeneration() + } + + @discardableResult + func advanceNavigationGeneration() -> GenerationToken { + let generation = navigationGenerations.advance() + scheduleNavigationSettled(for: generation) + return generation + } + + func scheduleNavigationSettledForCurrentGeneration() { + scheduleNavigationSettled(for: navigationGenerations.capture()) + } + + func isNavigationGenerationCurrent(_ generation: GenerationToken) -> Bool { + navigationGenerations.isCurrent(capturedToken: generation) + } + + @MainActor + private func prewarmLoadAndResetTargetIfCurrent( + generation: GenerationToken, + routes: [Route], + prewarmSelectedWallet: @escaping @MainActor (WalletId) async -> Void + ) async { + guard isNavigationGenerationCurrent(generation) else { return } + guard case let .selectedWallet(id) = routes.first else { return } + + await prewarmSelectedWallet(id) + } + + private func scheduleNavigationSettled(for generation: GenerationToken) { + navigationSettleTask?.cancel() + isNavigationSettled = false + + navigationSettleTask = Task { @MainActor [weak self] in + guard let self else { return } + + do { + try await sleep(navigationSettleDelay) + } catch { + return + } + + guard isNavigationGenerationCurrent(generation) else { return } + + isNavigationSettled = true + navigationSettleTask = nil + } + } + + private func currentRoute(router: Router) -> Route { + router.routes.last ?? router.default + } + + private func isDuplicateTopRoute(_ route: Route, router: Router) -> Bool { + currentRoute(router: router).isSameNavigationDestination(routeToCheck: route) + } +} diff --git a/ios/Cove/TaggedPresentation.swift b/ios/Cove/TaggedPresentation.swift new file mode 100644 index 000000000..8aea27128 --- /dev/null +++ b/ios/Cove/TaggedPresentation.swift @@ -0,0 +1,56 @@ +import SwiftUI + +protocol TaggedAlertPresentable { + associatedtype AlertPresentationContext + + func alert(context: AlertPresentationContext) -> AnyAlertBuilder +} + +protocol TaggedSheetPresentable { + associatedtype SheetPresentationContext + + func sheet(context: SheetPresentationContext) -> AnyView +} + +extension View { + func presentingAlert( + _ item: Binding?>, + context: Item.AlertPresentationContext, + defaultTitle: String = "Alert" + ) -> some View { + alert( + item.wrappedValue?.item.alert(context: context).title ?? defaultTitle, + isPresented: isPresenting(item), + presenting: item.wrappedValue, + actions: { $0.item.alert(context: context).actions }, + message: { $0.item.alert(context: context).message } + ) + } + + func presentingSheet( + _ item: Binding?>, + context: Item.SheetPresentationContext + ) -> some View { + sheet(item: item) { taggedItem in + taggedItem.item.sheet(context: context) + } + } + + func presentingFullScreenCover( + _ item: Binding?>, + context: Item.SheetPresentationContext + ) -> some View { + fullScreenCover(item: item) { taggedItem in + taggedItem.item.sheet(context: context) + } + } + + private func isPresenting(_ item: Binding?>) -> Binding { + Binding( + get: { item.wrappedValue != nil }, + set: { isPresented in + if !isPresented { item.wrappedValue = nil } + } + ) + } +} diff --git a/ios/Cove/Utils/AmountFormatter.swift b/ios/Cove/Utils/AmountFormatter.swift new file mode 100644 index 000000000..fb8576a8b --- /dev/null +++ b/ios/Cove/Utils/AmountFormatter.swift @@ -0,0 +1,81 @@ +struct AmountFormatter { + let metadata: WalletMetadata + + func amountFmt(_ amount: Amount) -> String { + switch metadata.selectedUnit { + case .btc: + amount.btcString() + case .sat: + amount.satsString() + } + } + + func displayAmount(_ amount: Amount, showUnit: Bool = true) -> String { + walletDisplayAmount(metadata: metadata, amount: amount, showUnit: showUnit) + } + + func displayAmountPendingFmt(_ amount: Amount) -> String? { + walletDisplayAmountPendingFmt(metadata: metadata, amount: amount) + } + + func displayAmountWithDirection( + _ amount: Amount, + direction: TransactionDirection + ) -> String { + walletDisplayAmountWithDirection( + metadata: metadata, + amount: amount, + direction: direction + ) + } + + func displaySentAndReceivedAmount(_ sentAndReceived: SentAndReceived) -> String { + walletDisplaySentAndReceivedAmount( + metadata: metadata, + sentAndReceived: sentAndReceived + ) + } + + func displayFiatAmount(_ amount: Double, withSuffix: Bool = true) -> String { + walletDisplayFiatAmount( + metadata: metadata, + amount: amount, + withSuffix: withSuffix + ) + } + + func displayFiatAmountPendingFmt( + _ amount: Double, + withSuffix: Bool = true + ) -> String? { + walletDisplayFiatAmountPendingFmt( + metadata: metadata, + amount: amount, + withSuffix: withSuffix + ) + } + + func displayFiatAmountWithDirection( + _ amount: Double, + direction: TransactionDirection, + withSuffix: Bool = true + ) -> String { + walletDisplayFiatAmountWithDirection( + metadata: metadata, + amount: amount, + direction: direction, + withSuffix: withSuffix + ) + } + + func amountInFiatCached(_ amount: Amount) -> Double? { + walletAmountInFiatCached(amount: amount) + } + + func amountFmtUnit(_ amount: Amount) -> String { + switch metadata.selectedUnit { + case .btc: amount.btcStringWithUnit() + case .sat: amount.satsStringWithUnit() + } + } +} diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index 80d062b28..63002009e 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -41,7 +41,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { let notify: () -> Void } -@Observable final class WalletManager: AnyReconciler, WalletManagerReconciler { +@Observable final class WalletManager: ReconcilingManager, WalletManagerReconciler { typealias Message = WalletManagerReconcileMessage typealias Action = WalletManagerAction @@ -57,6 +57,8 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { OSAllocatedUnfairLock(initialState: nil) @ObservationIgnored private var walletScanStarted = false + @ObservationIgnored + private weak var delegate: WalletManagerDelegate? var walletMetadata: WalletMetadata var ledgerState: WalletLedgerState @@ -73,7 +75,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } /// general wallet errors - var errorAlert: WalletErrorAlert? = nil + var errorAlert: TaggedItem? = nil /// errors in SendFlow var sendFlowErrorAlert: TaggedItem? = nil @@ -98,7 +100,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { /// scroll position for transaction list (persists across navigation) var scrolledTransactionId: String? - init(id: WalletId) throws { + init(id: WalletId, delegate: WalletManagerDelegate? = AppManager.shared) throws { let rust = try RustWalletManager(id: id) let initialState = rust.initialState() @@ -109,6 +111,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { self.ledgerState = initialState.ledgerState self.balancePresentation = initialState.balancePresentation self.balance = initialState.balance + self.delegate = delegate walletMetadata = initialState.metadata unsignedTransactions = initialState.unsignedTransactions @@ -139,7 +142,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } } - init(xpub: String) throws { + init(xpub: String, delegate: WalletManagerDelegate? = AppManager.shared) throws { let rust = try RustWalletManager.tryNewFromXpub(xpub: xpub) let initialState = rust.initialState() @@ -149,6 +152,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { self.ledgerState = initialState.ledgerState self.balancePresentation = initialState.balancePresentation self.balance = initialState.balance + self.delegate = delegate walletMetadata = initialState.metadata unsignedTransactions = initialState.unsignedTransactions id = initialState.metadata.id @@ -160,7 +164,8 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { tapSigner: TapSigner, deriveInfo: DeriveInfo, backup: Data? = nil, - birthday: WalletBirthday? = nil + birthday: WalletBirthday? = nil, + delegate: WalletManagerDelegate? = AppManager.shared ) throws { let rust = try RustWalletManager.tryNewFromTapSigner( tapSigner: tapSigner, @@ -176,6 +181,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { self.ledgerState = initialState.ledgerState self.balancePresentation = initialState.balancePresentation self.balance = initialState.balance + self.delegate = delegate walletMetadata = initialState.metadata unsignedTransactions = initialState.unsignedTransactions id = initialState.metadata.id @@ -190,6 +196,10 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } } + private var amountFormatter: AmountFormatter { + AmountFormatter(metadata: walletMetadata) + } + var hasTransactions: Bool { switch loadState { case .loading: false @@ -232,57 +242,37 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } func amountFmt(_ amount: Amount) -> String { - switch walletMetadata.selectedUnit { - case .btc: - amount.btcString() - case .sat: - amount.satsString() - } + amountFormatter.amountFmt(amount) } func displayAmount(_ amount: Amount, showUnit: Bool = true) -> String { - walletDisplayAmount(metadata: walletMetadata, amount: amount, showUnit: showUnit) + amountFormatter.displayAmount(amount, showUnit: showUnit) } func displayAmountPendingFmt(_ amount: Amount) -> String? { - walletDisplayAmountPendingFmt(metadata: walletMetadata, amount: amount) + amountFormatter.displayAmountPendingFmt(amount) } func displayAmountWithDirection( _ amount: Amount, direction: TransactionDirection ) -> String { - walletDisplayAmountWithDirection( - metadata: walletMetadata, - amount: amount, - direction: direction - ) + amountFormatter.displayAmountWithDirection(amount, direction: direction) } func displaySentAndReceivedAmount(_ sentAndReceived: SentAndReceived) -> String { - walletDisplaySentAndReceivedAmount( - metadata: walletMetadata, - sentAndReceived: sentAndReceived - ) + amountFormatter.displaySentAndReceivedAmount(sentAndReceived) } func displayFiatAmount(_ amount: Double, withSuffix: Bool = true) -> String { - walletDisplayFiatAmount( - metadata: walletMetadata, - amount: amount, - withSuffix: withSuffix - ) + amountFormatter.displayFiatAmount(amount, withSuffix: withSuffix) } func displayFiatAmountPendingFmt( _ amount: Double, withSuffix: Bool = true ) -> String? { - walletDisplayFiatAmountPendingFmt( - metadata: walletMetadata, - amount: amount, - withSuffix: withSuffix - ) + amountFormatter.displayFiatAmountPendingFmt(amount, withSuffix: withSuffix) } func displayFiatAmountWithDirection( @@ -290,23 +280,23 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { direction: TransactionDirection, withSuffix: Bool = true ) -> String { - walletDisplayFiatAmountWithDirection( - metadata: walletMetadata, - amount: amount, + amountFormatter.displayFiatAmountWithDirection( + amount, direction: direction, withSuffix: withSuffix ) } func amountInFiatCached(_ amount: Amount) -> Double? { - walletAmountInFiatCached(amount: amount) + amountFormatter.amountInFiatCached(amount) + } + + func displayConfirmationCount(_ confirmations: UInt32) -> String { + rust.displayConfirmationCount(confirmations: confirmations) } func amountFmtUnit(_ amount: Amount) -> String { - switch walletMetadata.selectedUnit { - case .btc: amount.btcStringWithUnit() - case .sat: amount.satsStringWithUnit() - } + amountFormatter.amountFmtUnit(amount) } func transactionDetails(for txId: TxId) async throws -> TransactionDetails { @@ -354,7 +344,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { await MainActor.run { transactionLockStates[txId] = state } - await AppManager.shared.reconcileAfterLabelsChanged(walletId: id) + await delegate?.reconcileAfterLabelsChanged(walletId: id) return state } @@ -364,7 +354,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { await MainActor.run { transactionLockStates[txId] = state } - await AppManager.shared.reconcileAfterLabelsChanged(walletId: id) + await delegate?.reconcileAfterLabelsChanged(walletId: id) return state } @@ -377,7 +367,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { @MainActor func importLabels(labels: Bip329Labels) throws { try LabelManager(id: id).import(labels: labels) - AppManager.shared.reconcileAfterLabelsChanged(walletId: id) + delegate?.reconcileAfterLabelsChanged(walletId: id) } @MainActor @@ -522,7 +512,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } case let .nodeConnectionFailed(error): - self.errorAlert = WalletErrorAlert.nodeConnectionFailed(error) + self.errorAlert = TaggedItem(WalletErrorAlert.nodeConnectionFailed(error)) self.logger.error(error) self.logger.error("set errorAlert") @@ -537,7 +527,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { self.sendFlowErrorAlert = TaggedItem(error) case let .hotWalletKeyMissing(walletId): - AppManager.shared.alertState = .init(.hotWalletKeyMissing(walletId: walletId)) + delegate?.showWalletAlert(.hotWalletKeyMissing(walletId: walletId)) case let .receiveAddressUpdated(state): self.receiveAddressState = state @@ -602,20 +592,12 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { } } - func reconcile(message: Message) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("reconcile \(message)") - self.apply(message) - } + func logReconcile(message: Message) { + logger.debug("reconcile \(message)") } - func reconcileMany(messages: [Message]) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("reconcile_messages: \(messages)") - messages.forEach { self.apply($0) } - } + func logReconcileMany(messages: [Message]) { + logger.debug("reconcile_messages: \(messages)") } func dispatch(action: Action) { @@ -656,6 +638,7 @@ private struct InitialScanLifecycleChangedHandler: @unchecked Sendable { self.balancePresentation = initialState.balancePresentation self.balance = initialState.balance self.walletMetadata = initialState.metadata + self.delegate = nil self.id = initialState.metadata.id self.unsignedTransactions = initialState.unsignedTransactions diff --git a/ios/Cove/WalletManagerDelegate.swift b/ios/Cove/WalletManagerDelegate.swift new file mode 100644 index 000000000..bdc24c86c --- /dev/null +++ b/ios/Cove/WalletManagerDelegate.swift @@ -0,0 +1,6 @@ +protocol WalletManagerDelegate: AnyObject { + @MainActor + func reconcileAfterLabelsChanged(walletId: WalletId) + + func showWalletAlert(_ alertState: AppAlertState) +} diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 93b0e6ba4..2b4270083 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -8127,18 +8127,11 @@ public func FfiConverterTypeRustConnectivityManager_lower(_ value: RustConnectiv public protocol RustImportWalletManagerProtocol: AnyObject, Sendable { - /** - * Action from the frontend to change the state of the view model - */ - func dispatch(action: ImportWalletManagerAction) - /** * Import wallet view from entered words */ func importWallet(enteredWords: [[String]]) throws -> WalletMetadata - func listenForUpdates(reconciler: ImportWalletManagerReconciler) - } open class RustImportWalletManager: RustImportWalletManagerProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -8201,18 +8194,6 @@ public convenience init() { - /** - * Action from the frontend to change the state of the view model - */ -open func dispatch(action: ImportWalletManagerAction) {try! rustCall() { - uniffiCallStatus in - uniffi_cove_fn_method_rustimportwalletmanager_dispatch( - self.uniffiCloneHandle(), - FfiConverterTypeImportWalletManagerAction_lower(action),uniffiCallStatus - ) -} -} - /** * Import wallet view from entered words */ @@ -8226,15 +8207,6 @@ open func importWallet(enteredWords: [[String]])throws -> WalletMetadata { }) } -open func listenForUpdates(reconciler: ImportWalletManagerReconciler) {try! rustCall() { - uniffiCallStatus in - uniffi_cove_fn_method_rustimportwalletmanager_listen_for_updates( - self.uniffiCloneHandle(), - FfiConverterCallbackInterfaceImportWalletManagerReconciler_lower(reconciler),uniffiCallStatus - ) -} -} - } @@ -9210,6 +9182,8 @@ public protocol RustWalletManagerProtocol: AnyObject, Sendable { */ func displayAmountWithDirection(amount: Amount, direction: TransactionDirection) -> String + func displayConfirmationCount(confirmations: UInt32) -> String + func displayFiatAmount(amount: Double, withSuffix: Bool) -> String /** @@ -10047,6 +10021,16 @@ open func displayAmountWithDirection(amount: Amount, direction: TransactionDirec }) } +open func displayConfirmationCount(confirmations: UInt32) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_rustwalletmanager_display_confirmation_count( + self.uniffiCloneHandle(), + FfiConverterUInt32.lower(confirmations),uniffiCallStatus + ) +}) +} + open func displayFiatAmount(amount: Double, withSuffix: Bool = true) -> String { return try! FfiConverterString.lift(try! rustCall() { uniffiCallStatus in @@ -13308,51 +13292,6 @@ public func FfiConverterTypeAppState_lower(_ value: AppState) -> RustBuffer { } -public struct AuthManagerState: Equatable, Hashable { - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init() { - } - - - - -} - -#if compiler(>=6) -extension AuthManagerState: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeAuthManagerState: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthManagerState { - return - AuthManagerState() - } - - public static func write(_ value: AuthManagerState, into buf: inout [UInt8]) { - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeAuthManagerState_lift(_ buf: RustBuffer) throws -> AuthManagerState { - return try FfiConverterTypeAuthManagerState.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeAuthManagerState_lower(_ value: AuthManagerState) -> RustBuffer { - return FfiConverterTypeAuthManagerState.lower(value) -} - - /** * Report of what happened during a backup import */ @@ -14259,63 +14198,6 @@ public func FfiConverterTypeCloudBackupRestoreReport_lower(_ value: CloudBackupR } -/** - * Retry instruction attached to a retryable deep verification failure - */ -public struct CloudBackupRetryContext: Equatable, Hashable { - public var issue: CloudBackupRetryIssue - public var action: CloudBackupRetryAction - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(issue: CloudBackupRetryIssue, action: CloudBackupRetryAction) { - self.issue = issue - self.action = action - } - - - - -} - -#if compiler(>=6) -extension CloudBackupRetryContext: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeCloudBackupRetryContext: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CloudBackupRetryContext { - return - try CloudBackupRetryContext( - issue: FfiConverterTypeCloudBackupRetryIssue.read(from: &buf), - action: FfiConverterTypeCloudBackupRetryAction.read(from: &buf) - ) - } - - public static func write(_ value: CloudBackupRetryContext, into buf: inout [UInt8]) { - FfiConverterTypeCloudBackupRetryIssue.write(value.issue, into: &buf) - FfiConverterTypeCloudBackupRetryAction.write(value.action, into: &buf) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCloudBackupRetryContext_lift(_ buf: RustBuffer) throws -> CloudBackupRetryContext { - return try FfiConverterTypeCloudBackupRetryContext.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCloudBackupRetryContext_lower(_ value: CloudBackupRetryContext) -> RustBuffer { - return FfiConverterTypeCloudBackupRetryContext.lower(value) -} - - /** * Top-level state snapshot exposed to platform managers */ @@ -15341,51 +15223,6 @@ public func FfiConverterTypeHistoricalPriceRecord_lower(_ value: HistoricalPrice } -public struct ImportWalletManagerState: Equatable, Hashable { - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init() { - } - - - - -} - -#if compiler(>=6) -extension ImportWalletManagerState: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeImportWalletManagerState: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ImportWalletManagerState { - return - ImportWalletManagerState() - } - - public static func write(_ value: ImportWalletManagerState, into buf: inout [UInt8]) { - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerState_lift(_ buf: RustBuffer) throws -> ImportWalletManagerState { - return try FfiConverterTypeImportWalletManagerState.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerState_lower(_ value: ImportWalletManagerState) -> RustBuffer { - return FfiConverterTypeImportWalletManagerState.lower(value) -} - - public struct InternalOnlyMetadata: Equatable, Hashable { public var addressIndex: AddressIndex? /** @@ -21443,68 +21280,6 @@ public func FfiConverterTypeCloudBackupRetryAction_lower(_ value: CloudBackupRet -/** - * Retry issue category for a user-visible verification retry - */ - -public enum CloudBackupRetryIssue: Equatable, Hashable { - - case connectivity - - - - - -} - -#if compiler(>=6) -extension CloudBackupRetryIssue: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeCloudBackupRetryIssue: FfiConverterRustBuffer { - typealias SwiftType = CloudBackupRetryIssue - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CloudBackupRetryIssue { - let variant: Int32 = try readInt(&buf) - switch variant { - - case 1: return .connectivity - - default: throw UniffiInternalError.unexpectedEnumCase - } - } - - public static func write(_ value: CloudBackupRetryIssue, into buf: inout [UInt8]) { - switch value { - - - case .connectivity: - writeInt(&buf, Int32(1)) - - } - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCloudBackupRetryIssue_lift(_ buf: RustBuffer) throws -> CloudBackupRetryIssue { - return try FfiConverterTypeCloudBackupRetryIssue.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCloudBackupRetryIssue_lower(_ value: CloudBackupRetryIssue) -> RustBuffer { - return FfiConverterTypeCloudBackupRetryIssue.lower(value) -} - - - /** * Root-level prompt the UI should show for the current cloud backup state */ @@ -23633,7 +23408,7 @@ public enum DeepVerificationFailure: Equatable, Hashable { /** * Transient iCloud/network/passkey error — safe to retry */ - case retry(message: String, detail: CloudBackupDetail?, retryContext: CloudBackupRetryContext? + case retry(message: String, detail: CloudBackupDetail?, retryAction: CloudBackupRetryAction? ) /** * Manifest missing, master key verified intact — recreate from local wallets @@ -23680,7 +23455,7 @@ public struct FfiConverterTypeDeepVerificationFailure: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .retry(message: try FfiConverterString.read(from: &buf), detail: try FfiConverterOptionTypeCloudBackupDetail.read(from: &buf), retryContext: try FfiConverterOptionTypeCloudBackupRetryContext.read(from: &buf) + case 1: return .retry(message: try FfiConverterString.read(from: &buf), detail: try FfiConverterOptionTypeCloudBackupDetail.read(from: &buf), retryAction: try FfiConverterOptionTypeCloudBackupRetryAction.read(from: &buf) ) case 2: return .recreateManifest(message: try FfiConverterString.read(from: &buf), warning: try FfiConverterString.read(from: &buf), detail: try FfiConverterOptionTypeCloudBackupDetail.read(from: &buf) @@ -23700,11 +23475,11 @@ public struct FfiConverterTypeDeepVerificationFailure: FfiConverterRustBuffer { switch value { - case let .retry(message,detail,retryContext): + case let .retry(message,detail,retryAction): writeInt(&buf, Int32(1)) FfiConverterString.write(message, into: &buf) FfiConverterOptionTypeCloudBackupDetail.write(detail, into: &buf) - FfiConverterOptionTypeCloudBackupRetryContext.write(retryContext, into: &buf) + FfiConverterOptionTypeCloudBackupRetryAction.write(retryAction, into: &buf) case let .recreateManifest(message,warning,detail): @@ -25867,124 +25642,6 @@ public func FfiConverterTypeImportWalletError_lower(_ value: ImportWalletError) } - -public enum ImportWalletManagerAction: Equatable, Hashable { - - case noOp - - - - - -} - -#if compiler(>=6) -extension ImportWalletManagerAction: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeImportWalletManagerAction: FfiConverterRustBuffer { - typealias SwiftType = ImportWalletManagerAction - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ImportWalletManagerAction { - let variant: Int32 = try readInt(&buf) - switch variant { - - case 1: return .noOp - - default: throw UniffiInternalError.unexpectedEnumCase - } - } - - public static func write(_ value: ImportWalletManagerAction, into buf: inout [UInt8]) { - switch value { - - - case .noOp: - writeInt(&buf, Int32(1)) - - } - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerAction_lift(_ buf: RustBuffer) throws -> ImportWalletManagerAction { - return try FfiConverterTypeImportWalletManagerAction.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerAction_lower(_ value: ImportWalletManagerAction) -> RustBuffer { - return FfiConverterTypeImportWalletManagerAction.lower(value) -} - - - - -public enum ImportWalletManagerReconcileMessage: Equatable, Hashable { - - case noOp - - - - - -} - -#if compiler(>=6) -extension ImportWalletManagerReconcileMessage: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeImportWalletManagerReconcileMessage: FfiConverterRustBuffer { - typealias SwiftType = ImportWalletManagerReconcileMessage - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ImportWalletManagerReconcileMessage { - let variant: Int32 = try readInt(&buf) - switch variant { - - case 1: return .noOp - - default: throw UniffiInternalError.unexpectedEnumCase - } - } - - public static func write(_ value: ImportWalletManagerReconcileMessage, into buf: inout [UInt8]) { - switch value { - - - case .noOp: - writeInt(&buf, Int32(1)) - - } - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerReconcileMessage_lift(_ buf: RustBuffer) throws -> ImportWalletManagerReconcileMessage { - return try FfiConverterTypeImportWalletManagerReconcileMessage.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeImportWalletManagerReconcileMessage_lower(_ value: ImportWalletManagerReconcileMessage) -> RustBuffer { - return FfiConverterTypeImportWalletManagerReconcileMessage.lower(value) -} - - - public enum InitError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -33734,6 +33391,15 @@ public enum WalletBirthday: Equatable, Hashable { +public func blockHeightFmt() -> String? { + return try! FfiConverterOptionString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_walletbirthday_block_height_fmt( + FfiConverterTypeWalletBirthday_lower(self),uniffiCallStatus + ) +}) +} + } @@ -37072,140 +36738,6 @@ public func FfiConverterCallbackInterfaceFfiReconcile_lower(_ v: FfiReconcile) - -public protocol ImportWalletManagerReconciler: AnyObject, Sendable { - - /** - * Tells the frontend to reconcile the view model changes - */ - func reconcile(message: ImportWalletManagerReconcileMessage) - -} - - -// Put the implementation in a struct so we don't pollute the top-level namespace -fileprivate struct UniffiCallbackInterfaceImportWalletManagerReconciler { - - // Create the VTable using a series of closures. - // Swift automatically converts these into C callback functions. - // - // Store the vtable directly. - static let vtable: UniffiVTableCallbackInterfaceImportWalletManagerReconciler = UniffiVTableCallbackInterfaceImportWalletManagerReconciler( - uniffiFree: { (uniffiHandle: UInt64) -> () in - do { - try FfiConverterCallbackInterfaceImportWalletManagerReconciler.handleMap.remove(handle: uniffiHandle) - } catch { - print("Uniffi callback interface ImportWalletManagerReconciler: handle missing in uniffiFree") - } - }, - uniffiClone: { (uniffiHandle: UInt64) -> UInt64 in - do { - return try FfiConverterCallbackInterfaceImportWalletManagerReconciler.handleMap.clone(handle: uniffiHandle) - } catch { - fatalError("Uniffi callback interface ImportWalletManagerReconciler: handle missing in uniffiClone") - } - }, - reconcile: { ( - uniffiHandle: UInt64, - message: RustBuffer, - uniffiOutReturn: UnsafeMutableRawPointer, - uniffiCallStatus: UnsafeMutablePointer - ) in - let makeCall = { - () throws -> () in - guard let uniffiObj = try? FfiConverterCallbackInterfaceImportWalletManagerReconciler.handleMap.get(handle: uniffiHandle) else { - throw UniffiInternalError.unexpectedStaleHandle - } - return uniffiObj.reconcile( - message: try FfiConverterTypeImportWalletManagerReconcileMessage_lift(message) - ) - } - - - let writeReturn = { () } - uniffiTraitInterfaceCall( - callStatus: uniffiCallStatus, - makeCall: makeCall, - writeReturn: writeReturn - ) - } - ) - - // Rust stores this pointer for future callback invocations, so it must live - // for the process lifetime (not just for the init function call). - static let vtablePtr: UnsafePointer = { - let ptr = UnsafeMutablePointer.allocate(capacity: 1) - ptr.initialize(to: vtable) - return UnsafePointer(ptr) - }() -} - -private func uniffiCallbackInitImportWalletManagerReconciler() { - uniffi_cove_fn_init_callback_vtable_importwalletmanagerreconciler(UniffiCallbackInterfaceImportWalletManagerReconciler.vtablePtr) -} - -// FfiConverter protocol for callback interfaces -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -fileprivate struct FfiConverterCallbackInterfaceImportWalletManagerReconciler { - fileprivate static let handleMap = UniffiHandleMap() -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -extension FfiConverterCallbackInterfaceImportWalletManagerReconciler : FfiConverter { - typealias SwiftType = ImportWalletManagerReconciler - typealias FfiType = UInt64 - -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public static func lift(_ handle: UInt64) throws -> SwiftType { - try handleMap.get(handle: handle) - } - -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { - let handle: UInt64 = try readInt(&buf) - return try lift(handle) - } - -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public static func lower(_ v: SwiftType) -> UInt64 { - return handleMap.insert(obj: v) - } - -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public static func write(_ v: SwiftType, into buf: inout [UInt8]) { - writeInt(&buf, lower(v)) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterCallbackInterfaceImportWalletManagerReconciler_lift(_ handle: UInt64) throws -> ImportWalletManagerReconciler { - return try FfiConverterCallbackInterfaceImportWalletManagerReconciler.lift(handle) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterCallbackInterfaceImportWalletManagerReconciler_lower(_ v: ImportWalletManagerReconciler) -> UInt64 { - return FfiConverterCallbackInterfaceImportWalletManagerReconciler.lower(v) -} - - - - public protocol OnboardingManagerReconciler: AnyObject, Sendable { func reconcile(message: OnboardingReconcileMessage) @@ -38419,30 +37951,6 @@ fileprivate struct FfiConverterOptionTypeCloudBackupProgress: FfiConverterRustBu } } -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -fileprivate struct FfiConverterOptionTypeCloudBackupRetryContext: FfiConverterRustBuffer { - typealias SwiftType = CloudBackupRetryContext? - - public static func write(_ value: SwiftType, into buf: inout [UInt8]) { - guard let value = value else { - writeInt(&buf, Int8(0)) - return - } - writeInt(&buf, Int8(1)) - FfiConverterTypeCloudBackupRetryContext.write(value, into: &buf) - } - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { - switch try readInt(&buf) as Int8 { - case 0: return nil - case 1: return try FfiConverterTypeCloudBackupRetryContext.read(from: &buf) - default: throw UniffiInternalError.unexpectedOptionalTag - } - } -} - #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -38635,6 +38143,30 @@ fileprivate struct FfiConverterOptionTypeBlockSizeLast: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeCloudBackupRetryAction: FfiConverterRustBuffer { + typealias SwiftType = CloudBackupRetryAction? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeCloudBackupRetryAction.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeCloudBackupRetryAction.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -41098,15 +40630,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustconnectivitymanager_state() != 43225) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustimportwalletmanager_dispatch() != 6624) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_rustimportwalletmanager_import_wallet() != 59354) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustimportwalletmanager_listen_for_updates() != 1669) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_rustonboardingmanager_current_wallet_id() != 41633) { return InitializationResult.apiChecksumMismatch } @@ -41365,6 +40891,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustwalletmanager_display_amount_with_direction() != 18925) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustwalletmanager_display_confirmation_count() != 48659) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustwalletmanager_display_fiat_amount() != 25193) { return InitializationResult.apiChecksumMismatch } @@ -41935,9 +41464,6 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_coincontrolmanagerreconciler_reconcile_many() != 55187) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_importwalletmanagerreconciler_reconcile() != 55979) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_onboardingmanagerreconciler_reconcile() != 11875) { return InitializationResult.apiChecksumMismatch } @@ -41971,7 +41497,6 @@ private let initializationResult: InitializationResult = { uniffiCallbackInitCloudBackupManagerReconciler() uniffiCallbackInitCoinControlManagerReconciler() uniffiCallbackInitFfiReconcile() - uniffiCallbackInitImportWalletManagerReconciler() uniffiCallbackInitOnboardingManagerReconciler() uniffiCallbackInitPendingWalletManagerReconciler() uniffiCallbackInitSendFlowManagerReconciler() diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove_types.swift b/ios/CoveCore/Sources/CoveCore/generated/cove_types.swift index 250632f00..f933b9661 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove_types.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove_types.swift @@ -2088,6 +2088,8 @@ public protocol FeeRateProtocol: AnyObject, Sendable { func satPerVb() -> Float + func satsPerVbyteString() -> String + } open class FeeRate: FeeRateProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -2160,6 +2162,15 @@ open func satPerVb() -> Float { }) } +open func satsPerVbyteString() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_types_fn_method_feerate_sats_per_vbyte_string( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + } @@ -2222,6 +2233,8 @@ public protocol FeeRateOptionProtocol: AnyObject, Sendable { func satPerVb() -> Float + func satsPerVbyteString() -> String + } open class FeeRateOption: FeeRateOptionProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -2332,6 +2345,15 @@ open func satPerVb() -> Float { }) } +open func satsPerVbyteString() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_types_fn_method_feerateoption_sats_per_vbyte_string( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + } @@ -2398,6 +2420,8 @@ public protocol FeeRateOptionWithTotalFeeProtocol: AnyObject, Sendable { func satPerVb() -> Float + func satsPerVbyteString() -> String + func totalFee() -> Amount? } @@ -2529,6 +2553,15 @@ open func satPerVb() -> Float { }) } +open func satsPerVbyteString() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_types_fn_method_feerateoptionwithtotalfee_sats_per_vbyte_string( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func totalFee() -> Amount? { return try! FfiConverterOptionTypeAmount.lift(try! rustCall() { uniffiCallStatus in @@ -6599,6 +6632,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_types_checksum_method_feerate_sat_per_vb() != 25940) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_types_checksum_method_feerate_sats_per_vbyte_string() != 28324) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_types_checksum_method_feerateoption_duration() != 37929) { return InitializationResult.apiChecksumMismatch } @@ -6614,6 +6650,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_types_checksum_method_feerateoption_sat_per_vb() != 38044) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_types_checksum_method_feerateoption_sats_per_vbyte_string() != 46546) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_duration() != 34003) { return InitializationResult.apiChecksumMismatch } @@ -6635,6 +6674,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sat_per_vb() != 61796) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sats_per_vbyte_string() != 22579) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_total_fee() != 12787) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/CoveTests/AmountFormatterTests.swift b/ios/CoveTests/AmountFormatterTests.swift new file mode 100644 index 000000000..fe4d3af2b --- /dev/null +++ b/ios/CoveTests/AmountFormatterTests.swift @@ -0,0 +1,89 @@ +@testable import Cove +import CoveCore +import XCTest + +final class AmountFormatterTests: XCTestCase { + func testBtcAmountFormattingMatchesExistingHelpers() { + var metadata = walletMetadataPreview() + metadata.selectedUnit = .btc + let amount = Amount.fromSat(sats: 123_456_789) + let formatter = AmountFormatter(metadata: metadata) + + XCTAssertEqual(formatter.amountFmt(amount), amount.btcString()) + XCTAssertEqual( + formatter.displayAmount(amount), + walletDisplayAmount(metadata: metadata, amount: amount, showUnit: true) + ) + XCTAssertEqual( + formatter.displayAmount(amount, showUnit: false), + walletDisplayAmount(metadata: metadata, amount: amount, showUnit: false) + ) + XCTAssertEqual(formatter.amountFmtUnit(amount), amount.btcStringWithUnit()) + } + + func testSatAmountFormattingMatchesExistingHelpers() { + var metadata = walletMetadataPreview() + metadata.selectedUnit = .sat + let amount = Amount.fromSat(sats: 123_456_789) + let formatter = AmountFormatter(metadata: metadata) + + XCTAssertEqual(formatter.amountFmt(amount), amount.satsString()) + XCTAssertEqual( + formatter.displayAmountWithDirection(amount, direction: .incoming), + walletDisplayAmountWithDirection( + metadata: metadata, + amount: amount, + direction: .incoming + ) + ) + XCTAssertEqual( + formatter.displayAmountPendingFmt(amount), + walletDisplayAmountPendingFmt(metadata: metadata, amount: amount) + ) + XCTAssertEqual(formatter.amountFmtUnit(amount), amount.satsStringWithUnit()) + } + + func testFiatFormattingMatchesExistingHelpers() { + var metadata = walletMetadataPreview() + metadata.sensitiveVisible = true + let formatter = AmountFormatter(metadata: metadata) + let amount = 1234.56 + + XCTAssertEqual( + formatter.displayFiatAmount(amount), + walletDisplayFiatAmount(metadata: metadata, amount: amount, withSuffix: true) + ) + XCTAssertEqual( + formatter.displayFiatAmount(amount, withSuffix: false), + walletDisplayFiatAmount(metadata: metadata, amount: amount, withSuffix: false) + ) + XCTAssertEqual( + formatter.displayFiatAmountWithDirection(amount, direction: .outgoing), + walletDisplayFiatAmountWithDirection( + metadata: metadata, + amount: amount, + direction: .outgoing, + withSuffix: true + ) + ) + XCTAssertEqual( + formatter.displayFiatAmountPendingFmt(amount), + walletDisplayFiatAmountPendingFmt(metadata: metadata, amount: amount, withSuffix: true) + ) + } + + func testSentAndReceivedFormattingMatchesExistingHelpers() { + let metadata = walletMetadataPreview() + let transaction = transactionsPreviewNew(confirmed: 0, unconfirmed: 1)[0] + let sentAndReceived = transaction.sentAndReceived() + let formatter = AmountFormatter(metadata: metadata) + + XCTAssertEqual( + formatter.displaySentAndReceivedAmount(sentAndReceived), + walletDisplaySentAndReceivedAmount( + metadata: metadata, + sentAndReceived: sentAndReceived + ) + ) + } +} diff --git a/ios/CoveTests/ICloudDriveHelperDecompositionTests.swift b/ios/CoveTests/ICloudDriveHelperDecompositionTests.swift new file mode 100644 index 000000000..216fd1046 --- /dev/null +++ b/ios/CoveTests/ICloudDriveHelperDecompositionTests.swift @@ -0,0 +1,302 @@ +@testable import Cove +import CoveCore +import XCTest + +final class ICloudDriveHelperDecompositionTests: XCTestCase { + func testICloudPathsDerivesNamespacePaths() throws { + let containerURL = URL(fileURLWithPath: "/tmp/Cove iCloud", isDirectory: true) + let namespace = "0123456789abcdef0123456789abcdef" + let paths = ICloudPaths(containerURL: containerURL) + + XCTAssertEqual(paths.dataDirectoryURL().path, "/tmp/Cove iCloud/Data") + XCTAssertEqual( + paths.namespacesRootURL().path, + "/tmp/Cove iCloud/Data/\(csppNamespacesSubdirectory())" + ) + XCTAssertEqual( + try paths.namespaceDirectoryURL(namespace: namespace).path, + "/tmp/Cove iCloud/Data/\(csppNamespacesSubdirectory())/\(namespace)" + ) + XCTAssertEqual( + try paths.walletsDirectoryURL(namespace: namespace).path, + "/tmp/Cove iCloud/Data/\(csppNamespacesSubdirectory())/\(namespace)/\(csppWalletsDirectory())" + ) + XCTAssertEqual( + paths.walletLocation(filename: "wallet-1.json"), + "\(csppWalletsDirectory())/wallet-1.json" + ) + } + + func testICloudPathsDerivesBackupLocations() throws { + let containerURL = URL(fileURLWithPath: "/tmp/Cove iCloud", isDirectory: true) + let namespace = "0123456789abcdef0123456789abcdef" + let paths = ICloudPaths(containerURL: containerURL) + + XCTAssertEqual( + try paths.backupFileURL( + namespace: namespace, + location: RemoteBackupLocation(relativePath: "wallet-legacy.json") + ).path, + "/tmp/Cove iCloud/Data/\(csppNamespacesSubdirectory())/\(namespace)/wallet-legacy.json" + ) + XCTAssertEqual( + try paths.backupFileURL( + namespace: namespace, + location: RemoteBackupLocation( + relativePath: "\(csppWalletsDirectory())/wallet-current.json" + ) + ).path, + "/tmp/Cove iCloud/Data/\(csppNamespacesSubdirectory())/\(namespace)/\(csppWalletsDirectory())/wallet-current.json" + ) + } + + func testMetadataVisibilityTimeoutMapsToUploadFailedWithoutSleeping() { + let clock = TestClock(start: Date(timeIntervalSince1970: 100)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 0.5, + pollInterval: 0.1, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + + XCTAssertThrowsError( + try machine.waitForMetadataVisibility(url: fileURL) { name, parentDirectoryURL, deadline in + XCTAssertEqual(name, "file.json") + XCTAssertEqual(parentDirectoryURL.path, "/tmp") + XCTAssertEqual(deadline, Date(timeIntervalSince1970: 100.5)) + throw ICloudDriveHelper.MetadataLookupError.timedOut( + "iCloud metadata query timed out for file.json" + ) + } + ) { error in + guard case let CloudStorageError.UploadFailed(message) = error else { + XCTFail("expected UploadFailed, got \(error)") + return + } + + XCTAssertEqual( + message, + "iCloud metadata lookup failed for file.json: iCloud metadata query timed out for file.json" + ) + } + XCTAssertTrue(clock.sleepIntervals.isEmpty) + } + + func testUploadCompletesAfterPollingWithInjectedClock() throws { + let clock = TestClock(start: Date(timeIntervalSince1970: 200)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 1, + pollInterval: 0.1, + progressLogInterval: 10, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + let resolvedItem = ResolvedMetadataItem( + url: URL(fileURLWithPath: "/tmp/metadata/file.json"), + metadataPath: "/tmp/metadata/file.json" + ) + let states: [UploadDownloadStateMachine.UploadState] = [ + .uploading, + .uploading, + .uploaded, + ] + var stateCallCount = 0 + var metadataLookupCount = 0 + var loggedMetadataItems = false + + try machine.waitForUpload( + url: fileURL, + waitForMetadataItem: { name, parentDirectoryURL, deadline in + metadataLookupCount += 1 + XCTAssertEqual(name, "file.json") + XCTAssertEqual(parentDirectoryURL.path, "/tmp") + XCTAssertEqual(deadline, Date(timeIntervalSince1970: 201)) + return resolvedItem + }, + uploadState: { url in + XCTAssertTrue(url == fileURL || url == resolvedItem.url) + defer { stateCallCount += 1 } + return states[min(stateCallCount, states.count - 1)] + }, + uploadDiagnostics: { _ in "diagnostics" }, + logMetadataItems: { _, _, _ in loggedMetadataItems = true } + ) + + XCTAssertEqual(metadataLookupCount, 1) + XCTAssertEqual(stateCallCount, 3) + XCTAssertEqual(clock.sleepIntervals, [0.1]) + XCTAssertFalse(loggedMetadataItems) + } + + func testUploadStallsUntilTimeoutWithInjectedClock() { + let clock = TestClock(start: Date(timeIntervalSince1970: 300)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 0.25, + pollInterval: 0.1, + progressLogInterval: 10, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + let resolvedItem = ResolvedMetadataItem(url: fileURL, metadataPath: "/tmp/file.json") + var loggedMetadataParent: URL? + var loggedMetadataReason: String? + var loggedMetadataFocus: String? + + XCTAssertThrowsError( + try machine.waitForUpload( + url: fileURL, + waitForMetadataItem: { _, _, _ in resolvedItem }, + uploadState: { _ in .uploading }, + uploadDiagnostics: { _ in "diagnostics" }, + logMetadataItems: { parentDirectoryURL, reason, focusName in + loggedMetadataParent = parentDirectoryURL + loggedMetadataReason = reason + loggedMetadataFocus = focusName + } + ) + ) { error in + guard case let CloudStorageError.Offline(message) = error else { + XCTFail("expected Offline, got \(error)") + return + } + + XCTAssertEqual(message, "iCloud upload timed out for file.json after 0.25s") + } + + XCTAssertEqual(clock.sleepIntervals, [0.1, 0.1, 0.1]) + XCTAssertEqual(loggedMetadataParent?.path, "/tmp") + XCTAssertEqual(loggedMetadataReason, "waitForUpload timeout") + XCTAssertEqual(loggedMetadataFocus, "file.json") + } + + func testDownloadReturnsInitialCoordinatedReadBeforeStatusCatchesUp() throws { + let clock = TestClock(start: Date(timeIntervalSince1970: 400)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 1, + pollInterval: 0.1, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + let resolvedItem = ResolvedMetadataItem( + url: URL(fileURLWithPath: "/tmp/metadata/file.json"), + metadataPath: "/tmp/metadata/file.json" + ) + var metadataLookupCount = 0 + var triggerDownloadCount = 0 + var coordinatedReadURLs: [URL] = [] + + let data = try machine.downloadFile( + url: fileURL, + recordId: "record-1", + fileExists: { _ in false }, + downloadState: { _ in .notDownloaded }, + waitForMetadataItem: { name, parentDirectoryURL, deadline in + metadataLookupCount += 1 + XCTAssertEqual(name, "file.json") + XCTAssertEqual(parentDirectoryURL.path, "/tmp") + XCTAssertEqual(deadline, Date(timeIntervalSince1970: 401)) + return resolvedItem + }, + triggerDownload: { url, recordId, filename in + triggerDownloadCount += 1 + XCTAssertEqual(url, resolvedItem.url) + XCTAssertEqual(recordId, "record-1") + XCTAssertEqual(filename, "file.json") + }, + coordinatedRead: { url in + coordinatedReadURLs.append(url) + return Data("downloaded".utf8) + } + ) + + XCTAssertEqual(data, Data("downloaded".utf8)) + XCTAssertEqual(metadataLookupCount, 1) + XCTAssertEqual(triggerDownloadCount, 1) + XCTAssertEqual(coordinatedReadURLs, [resolvedItem.url]) + XCTAssertTrue(clock.sleepIntervals.isEmpty) + } + + func testDownloadFinalCoordinatedReadCanWinAfterStatusTimeout() throws { + let clock = TestClock(start: Date(timeIntervalSince1970: 500)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 0.25, + pollInterval: 0.1, + progressLogInterval: 10, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + let resolvedItem = ResolvedMetadataItem(url: fileURL, metadataPath: "/tmp/file.json") + var coordinatedReadCount = 0 + + let data = try machine.downloadFile( + url: fileURL, + recordId: "record-1", + fileExists: { _ in false }, + downloadState: { _ in .notDownloaded }, + waitForMetadataItem: { _, _, _ in resolvedItem }, + triggerDownload: { _, _, _ in }, + coordinatedRead: { _ in + coordinatedReadCount += 1 + if coordinatedReadCount == 1 { + throw CocoaError(.fileReadNoSuchFile) + } + + return Data("materialized".utf8) + } + ) + + XCTAssertEqual(data, Data("materialized".utf8)) + XCTAssertEqual(coordinatedReadCount, 2) + XCTAssertEqual(clock.sleepIntervals, [0.1, 0.1, 0.1]) + } + + func testDownloadFailedStateMapsToDownloadFailed() { + let clock = TestClock(start: Date(timeIntervalSince1970: 600)) + let machine = UploadDownloadStateMachine( + defaultTimeout: 1, + pollInterval: 0.1, + progressLogInterval: 10, + clock: clock.makeClock() + ) + let fileURL = URL(fileURLWithPath: "/tmp/file.json") + let resolvedItem = ResolvedMetadataItem(url: fileURL, metadataPath: "/tmp/file.json") + + XCTAssertThrowsError( + try machine.downloadFile( + url: fileURL, + recordId: "record-1", + fileExists: { _ in false }, + downloadState: { _ in .failed(CocoaError(.fileReadCorruptFile)) }, + waitForMetadataItem: { _, _, _ in resolvedItem }, + triggerDownload: { _, _, _ in }, + coordinatedRead: { _ in throw CocoaError(.fileReadNoSuchFile) } + ) + ) { error in + guard case let CloudStorageError.DownloadFailed(message) = error else { + XCTFail("expected DownloadFailed, got \(error)") + return + } + + XCTAssertTrue(message.contains("iCloud download failed")) + } + } +} + +private final class TestClock: @unchecked Sendable { + private(set) var sleepIntervals: [TimeInterval] = [] + private var currentDate: Date + + init(start: Date) { + self.currentDate = start + } + + func makeClock() -> UploadDownloadStateMachine.Clock { + UploadDownloadStateMachine.Clock( + now: { self.currentDate }, + sleep: { interval in + self.sleepIntervals.append(interval) + self.currentDate = self.currentDate.addingTimeInterval(interval) + } + ) + } +} diff --git a/ios/CoveTests/NavigationCoordinatorTests.swift b/ios/CoveTests/NavigationCoordinatorTests.swift new file mode 100644 index 000000000..17137644e --- /dev/null +++ b/ios/CoveTests/NavigationCoordinatorTests.swift @@ -0,0 +1,172 @@ +@testable import Cove +import CoveCore +import XCTest + +@MainActor +final class NavigationCoordinatorTests: XCTestCase { + func testAdvanceMarksNavigationUnsettledUntilCurrentGenerationSettles() async { + let sleeper = NavigationTestSleeper() + let tracker = TestGenerationTracker() + let coordinator = NavigationCoordinator( + routeClient: TestNavigationRouteClient(), + navigationGenerations: tracker, + sleep: sleeper.sleep + ) + + let generation = coordinator.advanceNavigationGeneration() + + XCTAssertFalse(coordinator.isNavigationSettled) + XCTAssertTrue(coordinator.isNavigationGenerationCurrent(generation)) + + await sleeper.waitForSleepCount(1) + sleeper.resumeNext() + await Task.yield() + + XCTAssertTrue(coordinator.isNavigationSettled) + } + + func testOlderGenerationCannotSettleAfterNewGenerationAdvances() async { + let sleeper = NavigationTestSleeper() + let tracker = TestGenerationTracker() + let coordinator = NavigationCoordinator( + routeClient: TestNavigationRouteClient(), + navigationGenerations: tracker, + sleep: sleeper.sleep + ) + + let firstGeneration = coordinator.advanceNavigationGeneration() + await sleeper.waitForSleepCount(1) + + let secondGeneration = coordinator.advanceNavigationGeneration() + await sleeper.waitForSleepCount(2) + + XCTAssertFalse(coordinator.isNavigationGenerationCurrent(firstGeneration)) + XCTAssertTrue(coordinator.isNavigationGenerationCurrent(secondGeneration)) + + sleeper.resumeNext() + await Task.yield() + XCTAssertFalse(coordinator.isNavigationSettled) + + sleeper.resumeNext() + await Task.yield() + XCTAssertTrue(coordinator.isNavigationSettled) + } + + func testRapidSidebarNavigationOnlyRunsLatestAction() async { + let sleeper = NavigationTestSleeper() + let tracker = TestGenerationTracker() + let coordinator = NavigationCoordinator( + routeClient: TestNavigationRouteClient(), + navigationGenerations: tracker, + sleep: sleeper.sleep + ) + var isSidebarVisible = true + var actions: [String] = [] + + coordinator.closeSidebarThenNavigate(isSidebarVisible: &isSidebarVisible) { + actions.append("first") + } + + XCTAssertFalse(isSidebarVisible) + await sleeper.waitForSleepCount(2) + + isSidebarVisible = true + coordinator.closeSidebarThenNavigate(isSidebarVisible: &isSidebarVisible) { + actions.append("second") + } + + XCTAssertFalse(isSidebarVisible) + await sleeper.waitForSleepCount(4) + + sleeper.resumeFirstSleep(duration: .milliseconds(250)) + await Task.yield() + XCTAssertTrue(actions.isEmpty) + + sleeper.resumeFirstSleep(duration: .milliseconds(250)) + await Task.yield() + XCTAssertEqual(actions, ["second"]) + + sleeper.resumeAll() + } +} + +private final class TestGenerationTracker: GenerationTrackerProtocol, @unchecked Sendable { + private var current: UInt64 = 0 + + func advance() -> GenerationToken { + current += 1 + return GenerationToken(value: current) + } + + func capture() -> GenerationToken { + GenerationToken(value: current) + } + + func isCurrent(capturedToken: GenerationToken) -> Bool { + capturedToken.value == current + } +} + +@MainActor +private final class NavigationTestSleeper { + private struct PendingSleep { + let duration: Duration + let continuation: CheckedContinuation + } + + private var continuations: [PendingSleep] = [] + private(set) var sleepDurations: [Duration] = [] + + func sleep(_ duration: Duration) async throws { + sleepDurations.append(duration) + try await withCheckedThrowingContinuation { continuation in + continuations.append(PendingSleep(duration: duration, continuation: continuation)) + } + } + + func resumeNext() { + let pending = continuations.removeFirst() + pending.continuation.resume(returning: ()) + } + + func resumeFirstSleep(duration: Duration) { + guard let index = continuations.firstIndex(where: { $0.duration == duration }) else { + XCTFail("expected pending sleep for \(duration)") + return + } + + let pending = continuations.remove(at: index) + pending.continuation.resume(returning: ()) + } + + func resumeAll() { + while !continuations.isEmpty { + resumeNext() + } + } + + func waitForSleepCount( + _ count: Int, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0 ..< 50 { + if sleepDurations.count >= count { break } + await Task.yield() + } + + XCTAssertEqual(sleepDurations.count, count, file: file, line: line) + } +} + +private final class TestNavigationRouteClient: NavigationRouteClient { + func dispatch(action _: AppAction) throws {} + + func loadAndResetDefaultRoute(route _: Route) {} + + func resetAfterLoading(to _: [Route]) {} + + func resetDefaultRouteTo(route _: Route) {} + + func resetNestedRoutesTo(defaultRoute _: Route, nestedRoutes _: [Route]) {} +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 38182a08e..17774dbc9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1293,6 +1293,7 @@ dependencies = [ "bdk_wallet", "flume", "futures", + "parking_lot", "serde_json", "thiserror 2.0.18", "tokio", diff --git a/rust/crates/cove-bdk-progressive-scan/Cargo.toml b/rust/crates/cove-bdk-progressive-scan/Cargo.toml index b556b6d5d..7031b649e 100644 --- a/rust/crates/cove-bdk-progressive-scan/Cargo.toml +++ b/rust/crates/cove-bdk-progressive-scan/Cargo.toml @@ -15,5 +15,6 @@ tokio-util = "0.7.17" [dev-dependencies] bdk_wallet = { workspace = true, features = ["test-utils"] } +parking_lot = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/rust/crates/cove-bdk-progressive-scan/src/core.rs b/rust/crates/cove-bdk-progressive-scan/src/core.rs new file mode 100644 index 000000000..446b3b596 --- /dev/null +++ b/rust/crates/cove-bdk-progressive-scan/src/core.rs @@ -0,0 +1,282 @@ +use std::collections::{BTreeMap, HashSet}; + +use bdk_wallet::chain::{ + BlockId, CheckPoint, ConfirmationBlockTime, Indexed, TxUpdate, + bitcoin::{OutPoint, TxOut, Txid}, + spk_client::SpkWithExpectedTxids, +}; + +use crate::{ProgressTracker, ScanProgress, ScanUpdate}; + +pub(crate) struct SpkBatcher { + spks: I, +} + +impl SpkBatcher { + pub(crate) fn new(spks: I) -> Self { + Self { spks } + } +} + +impl SpkBatcher +where + I: Iterator>, +{ + pub(crate) fn next_batch(&mut self, batch_size: usize) -> Vec> { + (0..batch_size.max(1)).map_while(|_| self.spks.next()).collect() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct StopGapTracker { + gap_limit: usize, + last_revealed_index: Option, + unused_count: usize, + last_active_index: Option, +} + +impl StopGapTracker { + pub(crate) fn new(stop_gap: usize, last_revealed_index: Option) -> Self { + Self { + gap_limit: stop_gap.max(1), + last_revealed_index, + unused_count: 0, + last_active_index: None, + } + } + + pub(crate) fn record_spk(&mut self, spk_index: u32, used: bool) -> StopGapOutcome { + if used { + self.last_active_index = + Some(self.last_active_index.unwrap_or(spk_index).max(spk_index)); + self.unused_count = 0; + } else if self.last_revealed_index.is_none_or(|last_revealed| spk_index > last_revealed) { + self.unused_count = self.unused_count.saturating_add(1); + } + + StopGapOutcome { + last_active_index: self.last_active_index, + reached_stop_gap: self.unused_count >= self.gap_limit, + } + } + + pub(crate) fn last_active_index(&self) -> Option { + self.last_active_index + } + + pub(crate) fn reached_stop_gap(&self) -> bool { + self.unused_count >= self.gap_limit + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct StopGapOutcome { + pub(crate) last_active_index: Option, + pub(crate) reached_stop_gap: bool, +} + +pub(crate) struct ScanAccumulator { + progress: ProgressTracker, + inserted_txs: HashSet, + tx_update: TxUpdate, + last_active_indices: BTreeMap, +} + +impl ScanAccumulator +where + K: Ord + Clone, +{ + pub(crate) fn new(stop_gap: usize) -> Self { + Self { + progress: ProgressTracker::new(stop_gap), + inserted_txs: HashSet::new(), + tx_update: TxUpdate::default(), + last_active_indices: BTreeMap::new(), + } + } + + pub(crate) fn checked(&mut self, keychain: K, used: bool) -> ScanProgress { + self.progress.checked(keychain, used) + } + + pub(crate) fn insert_txid(&mut self, txid: Txid) -> bool { + self.inserted_txs.insert(txid) + } + + pub(crate) fn finish_keychain(&mut self, keychain: K, result: KeychainScanResult) { + self.tx_update.extend(result.update); + if let Some(last_active_index) = result.last_active_index { + self.last_active_indices.insert(keychain, last_active_index); + } + } + + pub(crate) fn into_response_parts(self) -> (TxUpdate, BTreeMap) { + (self.tx_update, self.last_active_indices) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct KeychainScanResult { + pub(crate) update: TxUpdate, + pub(crate) last_active_index: Option, +} + +impl KeychainScanResult { + pub(crate) fn new( + update: TxUpdate, + last_active_index: Option, + ) -> Self { + Self { update, last_active_index } + } +} + +pub(crate) fn scan_update_for_keychain( + keychain: K, + partial_update: &TxUpdate, + chain_update: Option, + last_active_index: Option, +) -> ScanUpdate +where + K: Ord, +{ + ScanUpdate { + chain_update, + tx_update: partial_update.clone(), + last_active_indices: last_active_index + .map(|index| BTreeMap::from([(keychain, index)])) + .unwrap_or_default(), + } +} + +pub(crate) fn insert_evicted_ats( + update: &mut TxUpdate, + spk: &SpkWithExpectedTxids, + found_txids: &HashSet, + start_time: u64, +) { + insert_evicted_ats_from_expected(update, &spk.expected_txids, found_txids, start_time); +} + +pub(crate) fn insert_evicted_ats_from_expected( + update: &mut TxUpdate, + expected_txids: &HashSet, + found_txids: &HashSet, + start_time: u64, +) { + update + .evicted_ats + .extend(expected_txids.difference(found_txids).map(|&txid| (txid, start_time))); +} + +pub(crate) enum TxStatusPlan { + Confirmed(ConfirmationBlockTime), + Seen, +} + +pub(crate) fn insert_tx_status( + update: &mut TxUpdate, + start_time: u64, + txid: Txid, + status: TxStatusPlan, +) { + match status { + TxStatusPlan::Confirmed(anchor) => { + update.anchors.insert((anchor, txid)); + } + TxStatusPlan::Seen => { + update.seen_ats.insert((txid, start_time)); + } + } +} + +pub(crate) fn confirmed_status( + height: u32, + hash: bdk_wallet::bitcoin::BlockHash, + time: u64, +) -> TxStatusPlan { + TxStatusPlan::Confirmed(ConfirmationBlockTime { + block_id: BlockId { height, hash }, + confirmation_time: time, + }) +} + +pub(crate) fn prevout_fetch_plan(update: &TxUpdate) -> Vec { + let mut unique_txs = HashSet::::new(); + let mut outpoints = Vec::new(); + + for tx in &update.txs { + if tx.is_coinbase() || !unique_txs.insert(tx.compute_txid()) { + continue; + } + + outpoints.extend(tx.input.iter().map(|input| input.previous_output)); + } + + outpoints +} + +pub(crate) fn insert_prevout_txouts( + update: &mut TxUpdate, + prevouts: impl IntoIterator, +) { + update.txouts.extend(prevouts); +} + +#[cfg(test)] +mod tests { + use bdk_wallet::chain::bitcoin::{ScriptBuf, Transaction, absolute, transaction}; + use bdk_wallet::chain::spk_client::SpkWithExpectedTxids; + + use super::{SpkBatcher, StopGapTracker, prevout_fetch_plan}; + + #[test] + fn stop_gap_ignores_unused_indexes_through_last_revealed() { + let mut tracker = StopGapTracker::new(2, Some(3)); + + for index in 0..=3 { + let outcome = tracker.record_spk(index, false); + assert!(!outcome.reached_stop_gap); + } + + assert!(!tracker.record_spk(4, false).reached_stop_gap); + assert!(tracker.record_spk(5, false).reached_stop_gap); + } + + #[test] + fn stop_gap_tracks_max_active_index_and_resets_gap() { + let mut tracker = StopGapTracker::new(2, None); + + tracker.record_spk(4, true); + tracker.record_spk(1, true); + + assert_eq!(tracker.last_active_index(), Some(4)); + assert!(!tracker.record_spk(5, false).reached_stop_gap); + assert!(tracker.record_spk(6, false).reached_stop_gap); + } + + #[test] + fn spk_batcher_uses_minimum_batch_size_of_one() { + let spks = (0..2).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let mut batcher = SpkBatcher::new(spks); + + assert_eq!(batcher.next_batch(0).len(), 1); + assert_eq!(batcher.next_batch(0).len(), 1); + assert!(batcher.next_batch(0).is_empty()); + } + + #[test] + fn prevout_fetch_plan_skips_coinbase_transactions() { + let mut update = bdk_wallet::chain::TxUpdate::default(); + update.txs.push( + Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + } + .into(), + ); + + assert!(prevout_fetch_plan(&update).is_empty()); + } +} diff --git a/rust/crates/cove-bdk-progressive-scan/src/electrum.rs b/rust/crates/cove-bdk-progressive-scan/src/electrum.rs index 7ba1c55b6..563b8fa70 100644 --- a/rust/crates/cove-bdk-progressive-scan/src/electrum.rs +++ b/rust/crates/cove-bdk-progressive-scan/src/electrum.rs @@ -14,7 +14,11 @@ use bdk_wallet::chain::{ }; use crate::{ - Error, ProgressTracker, ProgressiveScanner, Result, ScanEvent, ScanUpdate, + Error, ProgressiveScanner, Result, ScanEvent, + core::{ + KeychainScanResult, ScanAccumulator, SpkBatcher, StopGapTracker, TxStatusPlan, + insert_evicted_ats, insert_tx_status, prevout_fetch_plan, scan_update_for_keychain, + }, event::{clone_full_scan_response, send_complete_unless_cancelled, send_progress, send_update}, }; @@ -74,10 +78,7 @@ where None => None, }; - let mut tx_update = TxUpdate::::default(); - let mut last_active_indices = BTreeMap::::default(); - let mut inserted_txs = HashSet::::new(); - let mut progress = ProgressTracker::new(parts.stop_gap); + let mut scan = ScanAccumulator::::new(parts.stop_gap); for keychain in request.keychains() { if parts.cancel_token.is_cancelled() { return Err(Error::Cancelled); @@ -86,30 +87,28 @@ where let spks = request .iter_spks(keychain.clone()) .map(|(spk_i, spk)| (spk_i, SpkWithExpectedTxids::from(spk))); - if let Some(last_active_index) = populate_with_spks( + let keychain_result = populate_with_spks( self.client.as_ref(), start_time, &parts.events, tip_and_latest_blocks.as_ref(), &parts.cancel_token, - &mut progress, + &mut scan, keychain.clone(), - &mut inserted_txs, - &mut tx_update, spks, parts.last_revealed_indices.get(&keychain).copied(), parts.stop_gap, self.batch_size, self.fetch_prev_txouts, - )? { - last_active_indices.insert(keychain, last_active_index); - } + )?; + scan.finish_keychain(keychain, keychain_result); } if parts.cancel_token.is_cancelled() { return Err(Error::Cancelled); } + let (tx_update, last_active_indices) = scan.into_response_parts(); let chain_update = match tip_and_latest_blocks { Some((chain_tip, latest_blocks)) => { Some(chain_update(chain_tip, &latest_blocks, tx_update.anchors.iter().cloned())?) @@ -135,34 +134,30 @@ fn populate_with_spks( events: &flume::Sender>, tip_and_latest_blocks: Option<&(CheckPoint, BTreeMap)>, cancel_token: &tokio_util::sync::CancellationToken, - progress: &mut ProgressTracker, + scan: &mut ScanAccumulator, keychain: K, - inserted_txs: &mut HashSet, - tx_update: &mut TxUpdate, - mut spks_with_expected_txids: impl Iterator>, + spks_with_expected_txids: impl Iterator>, last_revealed_index: Option, stop_gap: usize, batch_size: usize, fetch_prev_txouts: bool, -) -> Result> +) -> Result where K: Ord + Clone, E: ElectrumApi, { - let mut stop_gap_unused_count = 0_usize; - let mut last_active_index = Option::::None; - let gap_limit = stop_gap.max(1); + let mut stop_gap = StopGapTracker::new(stop_gap, last_revealed_index); + let mut batcher = SpkBatcher::new(spks_with_expected_txids); + let mut update = TxUpdate::::default(); loop { if cancel_token.is_cancelled() { return Err(Error::Cancelled); } - let spks = - (0..batch_size).map_while(|_| spks_with_expected_txids.next()).collect::>(); - + let spks = batcher.next_batch(batch_size); if spks.is_empty() { - return Ok(last_active_index); + return Ok(KeychainScanResult::new(update, stop_gap.last_active_index())); } let spk_histories = client @@ -182,32 +177,29 @@ where for ((spk_index, spk), spk_history) in spks.into_iter().zip(spk_histories) { let used = !spk_history.is_empty(); - let scan_progress = progress.checked(keychain.clone(), used); + let scan_progress = scan.checked(keychain.clone(), used); send_progress(events, scan_progress); - - if spk_history.is_empty() { - if last_revealed_index.is_none_or(|last_revealed| spk_index > last_revealed) { - stop_gap_unused_count = stop_gap_unused_count.saturating_add(1); - } - } else { - last_active_index = Some(last_active_index.unwrap_or(spk_index).max(spk_index)); - stop_gap_unused_count = 0; - } + stop_gap.record_spk(spk_index, used); let spk_history_set = spk_history.iter().map(|res| res.tx_hash).collect::>(); - partial_update.evicted_ats.extend( - spk.expected_txids.difference(&spk_history_set).map(|&txid| (txid, start_time)), - ); + insert_evicted_ats(&mut partial_update, &spk, &spk_history_set, start_time); for tx_res in spk_history { - if inserted_txs.insert(tx_res.tx_hash) { - partial_update.txs.push(client.fetch_tx(tx_res.tx_hash)?); + if !scan.insert_txid(tx_res.tx_hash) { + continue; } + partial_update.txs.push(client.fetch_tx(tx_res.tx_hash)?); + match tx_res.height.try_into() { Ok(height) if height > 0 => pending_anchors.push((tx_res.tx_hash, height)), _ => { - partial_update.seen_ats.insert((tx_res.tx_hash, start_time)); + insert_tx_status( + &mut partial_update, + start_time, + tx_res.tx_hash, + TxStatusPlan::Seen, + ); } } } @@ -228,7 +220,12 @@ where if !pending_anchors.is_empty() { let anchors = batch_fetch_anchors(client, &pending_anchors)?; for (txid, anchor) in anchors { - partial_update.anchors.insert((anchor, txid)); + insert_tx_status( + &mut partial_update, + start_time, + txid, + TxStatusPlan::Confirmed(anchor), + ); } } @@ -245,20 +242,19 @@ where )?), None => None, }; - let scan_update = ScanUpdate { - chain_update: partial_chain_update, - tx_update: partial_update.clone(), - last_active_indices: last_active_index - .map(|index| BTreeMap::from([(keychain.clone(), index)])) - .unwrap_or_default(), - }; + let scan_update = scan_update_for_keychain( + keychain.clone(), + &partial_update, + partial_chain_update, + stop_gap.last_active_index(), + ); send_update(events, scan_update)?; - tx_update.extend(partial_update); + update.extend(partial_update); } - if stop_gap_unused_count >= gap_limit { - return Ok(last_active_index); + if stop_gap.reached_stop_gap() { + return Ok(KeychainScanResult::new(update, stop_gap.last_active_index())); } } } @@ -321,25 +317,16 @@ fn fetch_prev_txout( where E: ElectrumApi, { - let mut no_dup = HashSet::::new(); - for tx in &tx_update.txs { - if !tx.is_coinbase() && no_dup.insert(tx.compute_txid()) { - for vin in &tx.input { - let outpoint = vin.previous_output; - let vout = outpoint.vout; - let prev_tx = client.fetch_tx(outpoint.txid)?; - let txout = prev_tx - .output - .get(vout as usize) - .ok_or_else(|| { - electrum_client::Error::Message(format!( - "prevout {outpoint} does not exist" - )) - })? - .clone(); - tx_update.txouts.insert(outpoint, txout); - } - } + for outpoint in prevout_fetch_plan(tx_update) { + let prev_tx = client.fetch_tx(outpoint.txid)?; + let txout = prev_tx + .output + .get(outpoint.vout as usize) + .ok_or_else(|| { + electrum_client::Error::Message(format!("prevout {outpoint} does not exist")) + })? + .clone(); + tx_update.txouts.insert(outpoint, txout); } Ok(()) } @@ -405,14 +392,27 @@ fn fetch_tip_and_latest_blocks( Ok((new_tip, new_blocks)) } +fn chain_update( + mut tip: CheckPoint, + latest_blocks: &BTreeMap, + anchors: impl Iterator, +) -> std::result::Result { + for (anchor, _txid) in anchors { + let height = anchor.block_id.height; + if tip.get(height).is_none() && height <= tip.height() { + let hash = match latest_blocks.get(&height) { + Some(&hash) => hash, + None => anchor.block_id.hash, + }; + tip = tip.insert(BlockId { hash, height }); + } + } + Ok(tip) +} + #[cfg(test)] mod tests { - use std::{ - borrow::Borrow, - collections::{BTreeMap, HashSet, VecDeque}, - str::FromStr as _, - sync::{Arc, Mutex}, - }; + use std::{borrow::Borrow, collections::BTreeMap}; use bdk_electrum::{ BdkElectrumClient, @@ -423,29 +423,32 @@ mod tests { }, }; use bdk_wallet::{ - KeychainKind, Wallet, - bitcoin::Network, + KeychainKind, chain::{ - BlockId, CheckPoint, ConfirmationBlockTime, TxGraph, TxUpdate, - bitcoin::{ - BlockHash, Script, ScriptBuf, Transaction, Txid, absolute, block, transaction, - }, + BlockId, CheckPoint, ConfirmationBlockTime, TxGraph, + bitcoin::{Script, ScriptBuf, Transaction, Txid, block}, spk_client::{FullScanRequest, SpkWithExpectedTxids}, }, - test_utils::get_test_wpkh_and_change_desc, }; use tokio_util::sync::CancellationToken; - use crate::{Error, ProgressTracker, ProgressiveScanner, ScanEvent}; + use crate::{ + Error, ProgressiveScanner, ScanEvent, + core::ScanAccumulator, + test_fixtures::{ + QueuedResponse, ResponseQueue, SharedVec, block_hash, collect_events, empty_spks, + event_channel, external_request, revealed_external_request, test_transaction, txid, + }, + }; use super::{batch_fetch_anchors, chain_update, populate_with_spks}; #[derive(Debug, Clone)] struct FakeElectrum { fail_history: bool, - histories: Arc>>>, + histories: ResponseQueue, transactions: BTreeMap, - fetched_txids: Arc>>, + fetched_txids: SharedVec, history_response_limit: Option, merkle_response_limit: Option, } @@ -454,9 +457,9 @@ mod tests { fn empty_history() -> Self { Self { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::new())), + histories: ResponseQueue::empty(), transactions: BTreeMap::new(), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -465,9 +468,9 @@ mod tests { fn history_error() -> Self { Self { fail_history: true, - histories: Arc::new(Mutex::new(VecDeque::new())), + histories: ResponseQueue::empty(), transactions: BTreeMap::new(), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -477,13 +480,13 @@ mod tests { let txid = tx.compute_txid(); Self { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::from([ + histories: ResponseQueue::with_responses([ vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], Vec::new(), Vec::new(), - ]))), + ]), transactions: BTreeMap::from([(txid, tx)]), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -493,12 +496,12 @@ mod tests { let txid = tx.compute_txid(); Self { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::from([ + histories: ResponseQueue::with_responses([ vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], - ]))), + ]), transactions: BTreeMap::from([(txid, tx)]), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -510,14 +513,14 @@ mod tests { let txid = tx.compute_txid(); Self { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::from([ + histories: ResponseQueue::with_responses([ vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], Vec::new(), vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], Vec::new(), - ]))), + ]), transactions: BTreeMap::from([(txid, tx)]), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -526,13 +529,13 @@ mod tests { fn with_mempool_history(txid: Txid) -> Self { Self { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::from([ + histories: ResponseQueue::with_responses([ vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], Vec::new(), Vec::new(), - ]))), + ]), transactions: BTreeMap::new(), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, } @@ -547,7 +550,7 @@ mod tests { } fn fetched_txids(&self) -> Vec { - self.fetched_txids.lock().expect("fetched txid lock not poisoned").clone() + self.fetched_txids.snapshot() } } @@ -660,13 +663,19 @@ mod tests { return Err(electrum_client::Error::Message("history failed".to_string())); } - let mut histories = self.histories.lock().expect("history lock not poisoned"); + let mut histories = Vec::new(); + let limit = self.history_response_limit.unwrap_or(usize::MAX); - let histories = scripts - .into_iter() - .map(|_| histories.pop_front().unwrap_or_default()) - .take(self.history_response_limit.unwrap_or(usize::MAX)) - .collect(); + for _ in scripts.into_iter().take(limit) { + let history = match self.histories.pop() { + QueuedResponse::Response(history) => history, + QueuedResponse::Error => { + return Err(electrum_client::Error::Message("history failed".to_string())); + } + QueuedResponse::Exhausted => Vec::new(), + }; + histories.push(history); + } Ok(histories) } @@ -794,7 +803,7 @@ mod tests { } fn transaction_get(&self, txid: &Txid) -> Result { - self.fetched_txids.lock().expect("fetched txid lock not poisoned").push(*txid); + self.fetched_txids.push(*txid); self.transactions .get(txid) @@ -803,23 +812,6 @@ mod tests { } } - fn test_transaction() -> Transaction { - Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: Vec::new(), - output: Vec::new(), - } - } - - fn txid(byte: u8) -> Txid { - Txid::from_str(&format!("{byte:02x}{}", "00".repeat(31))).expect("valid txid") - } - - fn block_hash(byte: u8) -> BlockHash { - BlockHash::from_str(&format!("{byte:02x}{}", "00".repeat(31))).expect("valid block hash") - } - #[test] fn missing_batch_header_returns_provider_error() { let client = BdkElectrumClient::new(FakeElectrum::empty_history()); @@ -845,23 +837,19 @@ mod tests { #[test] fn empty_histories_emit_progress_without_updates_until_stop_gap() { let client = BdkElectrumClient::new(FakeElectrum::empty_history()); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); - let mut tx_update = TxUpdate::default(); - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let mut scan = ScanAccumulator::new(2); + let spks = empty_spks(5); - let last_active_index = populate_with_spks( + let result = populate_with_spks( &client, 0, &events, None, &cancel_token, - &mut progress, + &mut scan, "external", - &mut inserted_txs, - &mut tx_update, spks, None, 2, @@ -870,22 +858,17 @@ mod tests { ) .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); - assert_eq!(last_active_index, None); - assert!(tx_update.is_empty()); + let events = collect_events(receiver); + assert_eq!(result.last_active_index, None); + assert!(result.update.is_empty()); assert_eq!(events.len(), 2); assert!(events.iter().all(|event| matches!(event, ScanEvent::Progress(_)))); } #[test] fn successful_empty_scan_sends_complete_once_without_update() { - let request = FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(0, 5); + let (events, receiver) = event_channel(); let client = BdkElectrumClient::new(FakeElectrum::empty_history()); let response = ProgressiveScanner::builder() @@ -898,7 +881,7 @@ mod tests { .run() .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(response.tx_update.is_empty()); assert!(response.last_active_indices.is_empty()); assert_eq!( @@ -910,20 +893,8 @@ mod tests { #[test] fn revealed_range_is_scanned_before_stop_gap_extension() { - let (external_descriptor, internal_descriptor) = get_test_wpkh_and_change_desc(); - let mut wallet = Wallet::create(external_descriptor, internal_descriptor) - .network(Network::Signet) - .create_wallet_no_persist() - .expect("wallet is created"); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 3).last(); - let last_revealed_indices = wallet.spk_index().last_revealed_indices(); - let spks = wallet - .spk_index() - .unbounded_spk_iter(KeychainKind::External) - .expect("external keychain exists"); - let request = - FullScanRequest::builder_at(0).spks_for_keychain(KeychainKind::External, spks).build(); - let (events, receiver) = flume::unbounded(); + let (request, last_revealed_indices) = revealed_external_request(3); + let (events, receiver) = event_channel(); let client = BdkElectrumClient::new(FakeElectrum::empty_history()); let response = ProgressiveScanner::builder() @@ -953,13 +924,8 @@ mod tests { #[test] fn complete_send_failure_returns_channel_closed() { - let request = FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(0, 5); + let (events, receiver) = event_channel(); drop(receiver); let client = BdkElectrumClient::new(FakeElectrum::empty_history()); @@ -977,13 +943,8 @@ mod tests { #[test] fn mempool_history_emits_update_and_final_last_active_index() { - let request = FullScanRequest::builder_at(7) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(7, 5); + let (events, receiver) = event_channel(); let tx = test_transaction(); let txid = tx.compute_txid(); let client = BdkElectrumClient::new(FakeElectrum::with_mempool_transaction(tx)); @@ -998,7 +959,7 @@ mod tests { .run() .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); let updates = events .iter() .filter_map(|event| match event { @@ -1020,37 +981,33 @@ mod tests { let txid = tx.compute_txid(); let fake = FakeElectrum { fail_history: false, - histories: Arc::new(Mutex::new(VecDeque::from([ + histories: ResponseQueue::with_responses([ vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], vec![GetHistoryRes { height: 0, tx_hash: txid, fee: None }], Vec::new(), Vec::new(), - ]))), + ]), transactions: BTreeMap::from([(txid, tx)]), - fetched_txids: Arc::new(Mutex::new(Vec::new())), + fetched_txids: SharedVec::default(), history_response_limit: None, merkle_response_limit: None, }; let client = BdkElectrumClient::new(fake); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); - let mut tx_update = TxUpdate::default(); + let mut scan = ScanAccumulator::new(2); let spks = [4, 1, 5, 6] .into_iter() .map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); - let last_active_index = populate_with_spks( + let result = populate_with_spks( &client, 7, &events, None, &cancel_token, - &mut progress, + &mut scan, "external", - &mut inserted_txs, - &mut tx_update, spks, None, 2, @@ -1066,7 +1023,7 @@ mod tests { }) .expect("partial update is emitted"); - assert_eq!(last_active_index, Some(4)); + assert_eq!(result.last_active_index, Some(4)); assert_eq!(update.last_active_indices.get("external"), Some(&4)); } @@ -1076,23 +1033,19 @@ mod tests { let txid = tx.compute_txid(); let fake = FakeElectrum::with_duplicate_mempool_transaction(tx); let client = BdkElectrumClient::new(fake.clone()); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); - let mut tx_update = TxUpdate::default(); - let spks = (0..2).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let mut scan = ScanAccumulator::new(2); + let spks = empty_spks(2); - let last_active_index = populate_with_spks( + let result = populate_with_spks( &client, 7, &events, None, &cancel_token, - &mut progress, + &mut scan, "external", - &mut inserted_txs, - &mut tx_update, spks, None, 2, @@ -1108,12 +1061,12 @@ mod tests { }) .expect("partial update is emitted"); - assert_eq!(last_active_index, Some(1)); + assert_eq!(result.last_active_index, Some(1)); assert_eq!(fake.fetched_txids(), vec![txid]); assert_eq!(update.tx_update.txs.len(), 1); - assert_eq!(tx_update.txs.len(), 1); + assert_eq!(result.update.txs.len(), 1); assert!(update.tx_update.seen_ats.contains(&(txid, 7))); - assert!(tx_update.seen_ats.contains(&(txid, 7))); + assert!(result.update.seen_ats.contains(&(txid, 7))); } #[test] @@ -1133,7 +1086,7 @@ mod tests { (0..2).map(|index| (index, ScriptBuf::new())).collect::>(), ) .build(); - let (events, _receiver) = flume::unbounded(); + let (events, _receiver) = event_channel(); let response = ProgressiveScanner::builder() .request(request) @@ -1154,13 +1107,8 @@ mod tests { #[test] fn tx_graph_populates_transaction_cache_before_scan() { - let request = FullScanRequest::builder_at(7) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, _receiver) = flume::unbounded(); + let request = external_request(7, 5); + let (events, _receiver) = event_channel(); let tx = test_transaction(); let txid = tx.compute_txid(); let mut tx_graph = TxGraph::::default(); @@ -1201,13 +1149,8 @@ mod tests { #[test] fn update_send_failure_returns_channel_closed() { - let request = FullScanRequest::builder_at(7) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(7, 5); + let (events, receiver) = event_channel(); drop(receiver); let client = BdkElectrumClient::new(FakeElectrum::with_mempool_transaction(test_transaction())); @@ -1226,13 +1169,8 @@ mod tests { #[test] fn cancelled_scan_returns_cancelled_without_complete() { - let request = FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(0, 5); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); cancel_token.cancel(); let client = BdkElectrumClient::new(FakeElectrum::empty_history()); @@ -1247,20 +1185,15 @@ mod tests { .batch_size(1) .run(); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(matches!(result, Err(Error::Cancelled))); assert!(!events.iter().any(|event| matches!(event, ScanEvent::Complete(_)))); } #[test] fn provider_error_does_not_emit_complete() { - let request = FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(0, 5); + let (events, receiver) = event_channel(); let client = BdkElectrumClient::new(FakeElectrum::history_error()); let result = ProgressiveScanner::builder() @@ -1272,20 +1205,15 @@ mod tests { .batch_size(1) .run(); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(matches!(result, Err(Error::Electrum(_)))); assert!(!events.iter().any(|event| matches!(event, ScanEvent::Complete(_)))); } #[test] fn short_batch_history_response_returns_error() { - let request = FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); - let (events, receiver) = flume::unbounded(); + let request = external_request(0, 5); + let (events, receiver) = event_channel(); let client = BdkElectrumClient::new(FakeElectrum::with_history_response_limit(1)); let result = ProgressiveScanner::builder() @@ -1297,7 +1225,7 @@ mod tests { .batch_size(2) .run(); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(matches!( result, Err(Error::Electrum(electrum_client::Error::Message(message))) @@ -1306,21 +1234,3 @@ mod tests { assert!(!events.iter().any(|event| matches!(event, ScanEvent::Complete(_)))); } } - -fn chain_update( - mut tip: CheckPoint, - latest_blocks: &BTreeMap, - anchors: impl Iterator, -) -> std::result::Result { - for (anchor, _txid) in anchors { - let height = anchor.block_id.height; - if tip.get(height).is_none() && height <= tip.height() { - let hash = match latest_blocks.get(&height) { - Some(&hash) => hash, - None => anchor.block_id.hash, - }; - tip = tip.insert(BlockId { hash, height }); - } - } - Ok(tip) -} diff --git a/rust/crates/cove-bdk-progressive-scan/src/esplora.rs b/rust/crates/cove-bdk-progressive-scan/src/esplora.rs index 3900a0806..8cb4980f9 100644 --- a/rust/crates/cove-bdk-progressive-scan/src/esplora.rs +++ b/rust/crates/cove-bdk-progressive-scan/src/esplora.rs @@ -12,7 +12,12 @@ use bdk_wallet::chain::{ use futures::{TryStreamExt as _, future::BoxFuture, stream::FuturesOrdered}; use crate::{ - Error, ProgressTracker, ProgressiveScanner, Result, ScanEvent, ScanUpdate, + Error, ProgressiveScanner, Result, ScanEvent, + core::{ + KeychainScanResult, ScanAccumulator, SpkBatcher, StopGapTracker, confirmed_status, + insert_evicted_ats_from_expected, insert_prevout_txouts, insert_tx_status, + scan_update_for_keychain, + }, event::{ clone_full_scan_response, send_complete_async_unless_cancelled, send_progress, send_update_async, @@ -128,10 +133,7 @@ where let start_time = request.start_time(); let keychains = request.keychains(); let chain_tip = request.chain_tip(); - let mut tx_update = TxUpdate::::default(); - let mut inserted_txs = HashSet::::new(); - let mut last_active_indices = BTreeMap::::new(); - let mut progress = ProgressTracker::new(stop_gap); + let mut scan = ScanAccumulator::::new(stop_gap); for keychain in keychains { if cancel_token.is_cancelled() { @@ -147,8 +149,7 @@ where &chain_tip, latest_blocks.as_ref(), &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, keychain.clone(), keychain_spks, last_revealed_indices.get(&keychain).copied(), @@ -156,16 +157,14 @@ where parallel_requests, ) .await?; - tx_update.extend(keychain_result.update); - if let Some(last_active_index) = keychain_result.last_active_index { - last_active_indices.insert(keychain, last_active_index); - } + scan.finish_keychain(keychain, keychain_result); } if cancel_token.is_cancelled() { return Err(Error::Cancelled); } + let (tx_update, last_active_indices) = scan.into_response_parts(); let chain_update = match (chain_tip, latest_blocks) { (Some(chain_tip), Some(latest_blocks)) => { Some(client.chain_update(&latest_blocks, &chain_tip, &tx_update.anchors).await?) @@ -184,11 +183,6 @@ where Ok(response) } -struct KeychainScanResult { - update: TxUpdate, - last_active_index: Option, -} - #[allow(clippy::too_many_arguments)] async fn fetch_txs_with_keychain_spks( client: C, @@ -197,10 +191,9 @@ async fn fetch_txs_with_keychain_spks( chain_tip: &Option, latest_blocks: Option<&BTreeMap>, cancel_token: &tokio_util::sync::CancellationToken, - progress: &mut ProgressTracker, - inserted_txs: &mut HashSet, + scan: &mut ScanAccumulator, keychain: K, - mut keychain_spks: I, + keychain_spks: I, last_revealed_index: Option, stop_gap: usize, parallel_requests: usize, @@ -210,21 +203,24 @@ where I: Iterator> + Send, C: EsploraScanClient + Clone + Send + Sync, { - type TxsOfSpkIndex = (u32, Vec, HashSet); + type TxsOfSpkIndex = (u32, Vec, HashSet, HashSet); let mut update = TxUpdate::::default(); - let mut last_active_index = Option::::None; - let mut stop_gap_unused_count = 0usize; - let gap_limit = stop_gap.max(1); + let mut stop_gap = StopGapTracker::new(stop_gap, last_revealed_index); + let mut batcher = SpkBatcher::new(keychain_spks); loop { if cancel_token.is_cancelled() { return Err(Error::Cancelled); } - let handles = keychain_spks - .by_ref() - .take(parallel_requests) + let spks = batcher.next_batch(parallel_requests); + if spks.is_empty() { + break; + } + + let handles = spks + .into_iter() .map(|(spk_index, spk)| { let client = client.clone(); let expected_txids = spk.expected_txids; @@ -242,34 +238,27 @@ where } } let got_txids = spk_txs.iter().map(|tx| tx.txid).collect::>(); - let evicted_txids = - expected_txids.difference(&got_txids).copied().collect::>(); - Result::::Ok((spk_index, spk_txs, evicted_txids)) + Result::::Ok(( + spk_index, + spk_txs, + got_txids, + expected_txids, + )) } }) .collect::>(); - if handles.is_empty() { - break; - } - let mut partial_update = TxUpdate::::default(); - for (index, txs, evicted) in handles.try_collect::>().await? { + for (index, txs, got_txids, expected_txids) in + handles.try_collect::>().await? + { let used = !txs.is_empty(); - let scan_progress = progress.checked(keychain.clone(), used); + let scan_progress = scan.checked(keychain.clone(), used); send_progress(events, scan_progress); - - if txs.is_empty() { - if last_revealed_index.is_none_or(|last_revealed| index > last_revealed) { - stop_gap_unused_count = stop_gap_unused_count.saturating_add(1); - } - } else { - stop_gap_unused_count = 0; - last_active_index = Some(last_active_index.unwrap_or(index).max(index)); - } + stop_gap.record_spk(index, used); for tx in txs { - if !inserted_txs.insert(tx.txid) { + if !scan.insert_txid(tx.txid) { continue; } @@ -282,7 +271,12 @@ where ); insert_prevouts(&mut partial_update, tx.vin); } - partial_update.evicted_ats.extend(evicted.into_iter().map(|txid| (txid, start_time))); + insert_evicted_ats_from_expected( + &mut partial_update, + &expected_txids, + &got_txids, + start_time, + ); } if !partial_update.is_empty() { @@ -292,24 +286,23 @@ where ), _ => None, }; - let scan_update = ScanUpdate { - chain_update: partial_chain_update, - tx_update: partial_update.clone(), - last_active_indices: last_active_index - .map(|index| BTreeMap::from([(keychain.clone(), index)])) - .unwrap_or_default(), - }; + let scan_update = scan_update_for_keychain( + keychain.clone(), + &partial_update, + partial_chain_update, + stop_gap.last_active_index(), + ); send_update_async(events, scan_update).await?; update.extend(partial_update); } - if stop_gap_unused_count >= gap_limit { + if stop_gap.reached_stop_gap() { break; } } - Ok(KeychainScanResult { update, last_active_index }) + Ok(KeychainScanResult::new(update, stop_gap.last_active_index())) } fn insert_anchor_or_seen_at_from_status( @@ -325,11 +318,9 @@ fn insert_anchor_or_seen_at_from_status( block_time: Some(time), } = status { - let anchor = - ConfirmationBlockTime { block_id: BlockId { height, hash }, confirmation_time: time }; - update.anchors.insert((anchor, txid)); + insert_tx_status(update, start_time, txid, confirmed_status(height, hash, time)); } else { - update.seen_ats.insert((txid, start_time)); + insert_tx_status(update, start_time, txid, crate::core::TxStatusPlan::Seen); } } @@ -339,15 +330,18 @@ fn insert_prevouts( ) { let prevouts = esplora_inputs.into_iter().filter_map(|vin| Some((vin.txid, vin.vout, vin.prevout?))); - for (prev_txid, prev_vout, prev_txout) in prevouts { - update.txouts.insert( - OutPoint::new(prev_txid, prev_vout), - TxOut { - script_pubkey: prev_txout.scriptpubkey, - value: Amount::from_sat(prev_txout.value), - }, - ); - } + insert_prevout_txouts( + update, + prevouts.map(|(prev_txid, prev_vout, prev_txout)| { + ( + OutPoint::new(prev_txid, prev_vout), + TxOut { + script_pubkey: prev_txout.scriptpubkey, + value: Amount::from_sat(prev_txout.value), + }, + ) + }), + ); } async fn fetch_latest_blocks( @@ -446,57 +440,57 @@ where #[cfg(test)] mod tests { - use std::{ - collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, - str::FromStr as _, - sync::{Arc, Mutex}, - }; + use std::collections::{BTreeMap, BTreeSet}; use bdk_esplora::esplora_client; - use bdk_wallet::bitcoin::Network; use bdk_wallet::{ - KeychainKind, Wallet, + KeychainKind, chain::{ BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate, bitcoin::{Amount, OutPoint, ScriptBuf, Txid}, spk_client::SpkWithExpectedTxids, }, - test_utils::get_test_wpkh_and_change_desc, }; use futures::future::BoxFuture; use tokio_util::sync::CancellationToken; use crate::{ - Error, ProgressTracker, ScanEvent, + Error, ScanEvent, + core::ScanAccumulator, esplora::{ EsploraError, EsploraScanClient, fetch_txs_with_keychain_spks, insert_anchor_or_seen_at_from_status, insert_prevouts, run_with_esplora_client, }, + test_fixtures::{ + QueuedResponse, ResponseQueue, SharedCounter, block_hash, collect_events, + confirmed_esplora_tx, empty_spks, esplora_input, esplora_tx, event_channel, + external_request, revealed_external_request, txid, + }, }; #[derive(Clone, Debug)] struct FakeEsplora { - responses: Arc>>>>, - chain_update_requests: Arc>, + responses: ResponseQueue, + chain_update_requests: SharedCounter, } impl FakeEsplora { fn with_responses(responses: impl IntoIterator>) -> Self { Self { - responses: Arc::new(Mutex::new(responses.into_iter().map(Some).collect())), - chain_update_requests: Arc::new(Mutex::new(0)), + responses: ResponseQueue::with_responses(responses), + chain_update_requests: SharedCounter::default(), } } fn with_history_error() -> Self { Self { - responses: Arc::new(Mutex::new(VecDeque::from([None]))), - chain_update_requests: Arc::new(Mutex::new(0)), + responses: ResponseQueue::with_error(), + chain_update_requests: SharedCounter::default(), } } fn chain_update_count(&self) -> usize { - *self.chain_update_requests.lock().expect("request count lock not poisoned") + self.chain_update_requests.get() } } @@ -507,13 +501,13 @@ mod tests { _: Option, ) -> BoxFuture<'a, std::result::Result, EsploraError>> { Box::pin(async move { - match self.responses.lock().expect("response lock not poisoned").pop_front() { - Some(Some(txs)) => Ok(txs), - Some(None) => Err(Box::new(esplora_client::Error::HttpResponse { + match self.responses.pop() { + QueuedResponse::Response(txs) => Ok(txs), + QueuedResponse::Error => Err(Box::new(esplora_client::Error::HttpResponse { status: 500, message: "history failed".to_string(), })), - None => Ok(Vec::new()), + QueuedResponse::Exhausted => Ok(Vec::new()), } }) } @@ -525,10 +519,10 @@ mod tests { _: &'a BTreeSet<(ConfirmationBlockTime, Txid)>, ) -> BoxFuture<'a, std::result::Result> { - let chain_update_requests = Arc::clone(&self.chain_update_requests); + let chain_update_requests = self.chain_update_requests.clone(); Box::pin(async move { - *chain_update_requests.lock().expect("request count lock not poisoned") += 1; + chain_update_requests.increment(); Ok(local_tip.clone()) }) } @@ -537,12 +531,11 @@ mod tests { #[test] fn empty_histories_emit_progress_without_updates_until_stop_gap() { let fake = FakeEsplora::with_responses([Vec::new(), Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = None; - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake, @@ -551,8 +544,7 @@ mod tests { &chain_tip, None, &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -561,7 +553,7 @@ mod tests { )) .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(result.update.is_empty()); assert_eq!(result.last_active_index, None); assert_eq!(events.len(), 2); @@ -572,12 +564,11 @@ mod tests { fn mempool_history_emits_update_and_final_last_active_index() { let txid = txid(8); let fake = FakeEsplora::with_responses([vec![esplora_tx(txid)], Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = None; - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake, @@ -586,8 +577,7 @@ mod tests { &chain_tip, None, &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -596,7 +586,7 @@ mod tests { )) .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); let updates = events .iter() .filter_map(|event| match event { @@ -622,10 +612,9 @@ mod tests { Vec::new(), Vec::new(), ]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = None; let spks = [4, 1, 5, 6] .into_iter() @@ -638,8 +627,7 @@ mod tests { &chain_tip, None, &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -663,21 +651,14 @@ mod tests { #[test] fn confirmed_history_emits_partial_chain_update() { let txid = txid(9); - let mut tx = esplora_tx(txid); - tx.status = esplora_client::TxStatus { - confirmed: true, - block_height: Some(2), - block_hash: Some(block_hash(2)), - block_time: Some(123), - }; + let tx = confirmed_esplora_tx(txid, 2, block_hash(2), 123); let fake = FakeEsplora::with_responses([vec![tx], Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = Some(CheckPoint::new(BlockId { height: 1, hash: block_hash(1) })); let latest_blocks = BTreeMap::new(); - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake, @@ -686,8 +667,7 @@ mod tests { &chain_tip, Some(&latest_blocks), &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -715,25 +695,15 @@ mod tests { #[test] fn duplicate_confirmed_history_does_not_emit_duplicate_update() { let txid = txid(10); - let make_tx = || { - let mut tx = esplora_tx(txid); - tx.status = esplora_client::TxStatus { - confirmed: true, - block_height: Some(2), - block_hash: Some(block_hash(2)), - block_time: Some(123), - }; - tx - }; + let make_tx = || confirmed_esplora_tx(txid, 2, block_hash(2), 123); let fake = FakeEsplora::with_responses([vec![make_tx()], vec![make_tx()], Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = Some(CheckPoint::new(BlockId { height: 1, hash: block_hash(1) })); let latest_blocks = BTreeMap::new(); - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake.clone(), @@ -742,8 +712,7 @@ mod tests { &chain_tip, Some(&latest_blocks), &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -769,14 +738,9 @@ mod tests { #[test] fn update_send_failure_returns_channel_closed() { - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); + let request = external_request(0, 5); let fake = FakeEsplora::with_responses([vec![esplora_tx(txid(8))], Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); drop(receiver); let cancel_token = CancellationToken::new(); @@ -797,13 +761,12 @@ mod tests { #[test] fn cancelled_scan_returns_cancelled_without_update() { let fake = FakeEsplora::with_responses([Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); cancel_token.cancel(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = None; - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake, @@ -812,8 +775,7 @@ mod tests { &chain_tip, None, &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -828,12 +790,11 @@ mod tests { #[test] fn provider_error_does_not_emit_update() { let fake = FakeEsplora::with_history_error(); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); - let mut progress = ProgressTracker::new(2); - let mut inserted_txs = HashSet::new(); + let mut scan = ScanAccumulator::new(2); let chain_tip = None; - let spks = (0..5).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))); + let spks = empty_spks(5); let result = futures::executor::block_on(fetch_txs_with_keychain_spks( fake, @@ -842,8 +803,7 @@ mod tests { &chain_tip, None, &cancel_token, - &mut progress, - &mut inserted_txs, + &mut scan, "external", spks, None, @@ -857,14 +817,9 @@ mod tests { #[test] fn successful_empty_scan_sends_complete_once_without_update() { - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); + let request = external_request(0, 5); let fake = FakeEsplora::with_responses([Vec::new(), Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); let response = futures::executor::block_on(run_with_esplora_client( @@ -879,7 +834,7 @@ mod tests { )) .expect("scan succeeds"); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(response.tx_update.is_empty()); assert!(response.last_active_indices.is_empty()); assert_eq!( @@ -891,22 +846,9 @@ mod tests { #[test] fn revealed_range_is_scanned_before_stop_gap_extension() { - let (external_descriptor, internal_descriptor) = get_test_wpkh_and_change_desc(); - let mut wallet = Wallet::create(external_descriptor, internal_descriptor) - .network(Network::Signet) - .create_wallet_no_persist() - .expect("wallet is created"); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 3).last(); - let last_revealed_indices = wallet.spk_index().last_revealed_indices(); - let spks = wallet - .spk_index() - .unbounded_spk_iter(KeychainKind::External) - .expect("external keychain exists"); - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain(KeychainKind::External, spks) - .build(); + let (request, last_revealed_indices) = revealed_external_request(3); let fake = FakeEsplora::with_responses(std::iter::repeat_with(Vec::new).take(16)); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); let response = futures::executor::block_on(run_with_esplora_client( @@ -937,14 +879,9 @@ mod tests { #[test] fn complete_send_failure_returns_channel_closed() { - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); + let request = external_request(0, 5); let fake = FakeEsplora::with_responses([Vec::new(), Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); drop(receiver); let cancel_token = CancellationToken::new(); @@ -964,14 +901,9 @@ mod tests { #[test] fn cancelled_scan_returns_cancelled_without_complete() { - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); + let request = external_request(0, 5); let fake = FakeEsplora::with_responses([Vec::new(), Vec::new(), Vec::new()]); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); cancel_token.cancel(); @@ -986,21 +918,16 @@ mod tests { 1, )); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(matches!(result, Err(Error::Cancelled))); assert!(!events.iter().any(|event| matches!(event, ScanEvent::Complete(_)))); } #[test] fn provider_error_does_not_emit_complete() { - let request = bdk_wallet::chain::spk_client::FullScanRequest::builder_at(0) - .spks_for_keychain( - "external", - (0..5).map(|index| (index, ScriptBuf::new())).collect::>(), - ) - .build(); + let request = external_request(0, 5); let fake = FakeEsplora::with_history_error(); - let (events, receiver) = flume::unbounded(); + let (events, receiver) = event_channel(); let cancel_token = CancellationToken::new(); let result = futures::executor::block_on(run_with_esplora_client( @@ -1014,7 +941,7 @@ mod tests { 1, )); - let events = receiver.try_iter().collect::>(); + let events = collect_events(receiver); assert!(matches!(result, Err(Error::Esplora(_)))); assert!(!events.iter().any(|event| matches!(event, ScanEvent::Complete(_)))); } @@ -1131,49 +1058,4 @@ mod tests { ); assert_eq!(update.txouts.len(), 1); } - - fn esplora_input( - txid: Txid, - vout: u32, - prevout: Option<(u64, ScriptBuf)>, - ) -> esplora_client::api::Vin { - esplora_client::api::Vin { - txid, - vout, - prevout: prevout - .map(|(value, scriptpubkey)| esplora_client::api::PrevOut { value, scriptpubkey }), - scriptsig: ScriptBuf::new(), - witness: Vec::new(), - sequence: 0, - is_coinbase: false, - } - } - - fn txid(byte: u8) -> Txid { - Txid::from_str(&format!("{byte:02x}{}", "00".repeat(31))).expect("valid txid") - } - - fn block_hash(byte: u8) -> bdk_wallet::chain::bitcoin::BlockHash { - bdk_wallet::chain::bitcoin::BlockHash::from_str(&format!("{byte:02x}{}", "00".repeat(31))) - .expect("valid block hash") - } - - fn esplora_tx(txid: Txid) -> esplora_client::Tx { - esplora_client::Tx { - txid, - version: 2, - locktime: 0, - vin: Vec::new(), - vout: Vec::new(), - size: 0, - weight: 0, - status: esplora_client::TxStatus { - confirmed: false, - block_height: None, - block_hash: None, - block_time: None, - }, - fee: 0, - } - } } diff --git a/rust/crates/cove-bdk-progressive-scan/src/lib.rs b/rust/crates/cove-bdk-progressive-scan/src/lib.rs index c5cb408ae..94fdf13be 100644 --- a/rust/crates/cove-bdk-progressive-scan/src/lib.rs +++ b/rust/crates/cove-bdk-progressive-scan/src/lib.rs @@ -1,9 +1,12 @@ +mod core; mod electrum; mod error; mod esplora; mod event; mod progress; mod scanner; +#[cfg(test)] +mod test_fixtures; pub use electrum::ProgressiveElectrumScanner; pub use error::{Error, Result}; diff --git a/rust/crates/cove-bdk-progressive-scan/src/test_fixtures.rs b/rust/crates/cove-bdk-progressive-scan/src/test_fixtures.rs new file mode 100644 index 000000000..c819f49fc --- /dev/null +++ b/rust/crates/cove-bdk-progressive-scan/src/test_fixtures.rs @@ -0,0 +1,198 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + str::FromStr as _, + sync::Arc, +}; + +use bdk_esplora::esplora_client; +use bdk_wallet::{ + KeychainKind, Wallet, + bitcoin::Network, + chain::{ + bitcoin::{BlockHash, ScriptBuf, Transaction, Txid, absolute, transaction}, + spk_client::{FullScanRequest, SpkWithExpectedTxids}, + }, + test_utils::get_test_wpkh_and_change_desc, +}; +use parking_lot::Mutex; + +use crate::ScanEvent; + +#[derive(Debug, Clone)] +pub(crate) struct ResponseQueue(Arc>>>>); + +impl ResponseQueue { + pub(crate) fn empty() -> Self { + Self(Arc::new(Mutex::new(VecDeque::new()))) + } + + pub(crate) fn with_responses(responses: impl IntoIterator>) -> Self { + Self(Arc::new(Mutex::new(responses.into_iter().map(Some).collect()))) + } + + pub(crate) fn with_error() -> Self { + Self(Arc::new(Mutex::new(VecDeque::from([None])))) + } + + pub(crate) fn pop(&self) -> QueuedResponse { + match self.0.lock().pop_front() { + Some(Some(response)) => QueuedResponse::Response(response), + Some(None) => QueuedResponse::Error, + None => QueuedResponse::Exhausted, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) enum QueuedResponse { + Response(Vec), + Error, + Exhausted, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SharedCounter(Arc>); + +impl SharedCounter { + pub(crate) fn increment(&self) { + *self.0.lock() += 1; + } + + pub(crate) fn get(&self) -> usize { + *self.0.lock() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SharedVec(Arc>>); + +impl Default for SharedVec { + fn default() -> Self { + Self(Arc::new(Mutex::new(Vec::new()))) + } +} + +impl SharedVec { + pub(crate) fn push(&self, value: T) { + self.0.lock().push(value); + } +} + +impl SharedVec +where + T: Clone, +{ + pub(crate) fn snapshot(&self) -> Vec { + self.0.lock().clone() + } +} + +pub(crate) fn event_channel() -> (flume::Sender>, flume::Receiver>) { + flume::unbounded() +} + +pub(crate) fn collect_events(receiver: flume::Receiver>) -> Vec> { + receiver.try_iter().collect() +} + +pub(crate) fn empty_spks(count: u32) -> impl Iterator { + (0..count).map(|index| (index, SpkWithExpectedTxids::from(ScriptBuf::new()))) +} + +pub(crate) fn external_request(start_time: u64, count: u32) -> FullScanRequest<&'static str> { + FullScanRequest::builder_at(start_time) + .spks_for_keychain( + "external", + (0..count).map(|index| (index, ScriptBuf::new())).collect::>(), + ) + .build() +} + +pub(crate) fn revealed_external_request( + last_revealed: u32, +) -> (FullScanRequest, BTreeMap) { + let (external_descriptor, internal_descriptor) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(external_descriptor, internal_descriptor) + .network(Network::Signet) + .create_wallet_no_persist() + .expect("wallet is created"); + let _ = wallet.reveal_addresses_to(KeychainKind::External, last_revealed).last(); + let last_revealed_indices = wallet.spk_index().last_revealed_indices(); + let spks = wallet + .spk_index() + .unbounded_spk_iter(KeychainKind::External) + .expect("external keychain exists"); + let request = + FullScanRequest::builder_at(0).spks_for_keychain(KeychainKind::External, spks).build(); + + (request, last_revealed_indices) +} + +pub(crate) fn test_transaction() -> Transaction { + Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + } +} + +pub(crate) fn txid(byte: u8) -> Txid { + Txid::from_str(&format!("{byte:02x}{}", "00".repeat(31))).expect("valid txid") +} + +pub(crate) fn block_hash(byte: u8) -> BlockHash { + BlockHash::from_str(&format!("{byte:02x}{}", "00".repeat(31))).expect("valid block hash") +} + +pub(crate) fn esplora_tx(txid: Txid) -> esplora_client::Tx { + esplora_client::Tx { + txid, + version: 2, + locktime: 0, + vin: Vec::new(), + vout: Vec::new(), + size: 0, + weight: 0, + status: esplora_client::TxStatus { + confirmed: false, + block_height: None, + block_hash: None, + block_time: None, + }, + fee: 0, + } +} + +pub(crate) fn confirmed_esplora_tx( + txid: Txid, + height: u32, + hash: BlockHash, + time: u64, +) -> esplora_client::Tx { + let mut tx = esplora_tx(txid); + tx.status = esplora_client::TxStatus { + confirmed: true, + block_height: Some(height), + block_hash: Some(hash), + block_time: Some(time), + }; + tx +} + +pub(crate) fn esplora_input( + txid: Txid, + vout: u32, + prevout: Option<(u64, ScriptBuf)>, +) -> esplora_client::api::Vin { + esplora_client::api::Vin { + txid, + vout, + prevout: prevout + .map(|(value, scriptpubkey)| esplora_client::api::PrevOut { value, scriptpubkey }), + scriptsig: ScriptBuf::new(), + witness: Vec::new(), + sequence: 0, + is_coinbase: false, + } +} diff --git a/rust/crates/cove-types/src/fees.rs b/rust/crates/cove-types/src/fees.rs index b34818bc6..9d30a287d 100644 --- a/rust/crates/cove-types/src/fees.rs +++ b/rust/crates/cove-types/src/fees.rs @@ -59,6 +59,11 @@ impl FeeRate { pub fn sat_per_vb(&self) -> f32 { self.0.to_sat_per_kwu() as f32 / (1000_f32 / 4.0) } + + #[must_use] + pub fn sats_per_vbyte_string(&self) -> String { + format!("{:.2} sats/vbyte", self.sat_per_vb()) + } } // MARK: FeeRateOptions @@ -119,6 +124,11 @@ impl FeeRateOption { self.fee_rate.sat_per_vb() } + #[must_use] + pub fn sats_per_vbyte_string(&self) -> String { + self.fee_rate.sats_per_vbyte_string() + } + #[must_use] pub fn duration(&self) -> String { self.fee_speed.duration() @@ -404,6 +414,11 @@ impl FeeRateOptionWithTotalFee { self.fee_rate.sat_per_vb() } + #[must_use] + pub fn sats_per_vbyte_string(&self) -> String { + self.fee_rate.sats_per_vbyte_string() + } + #[must_use] pub fn duration(&self) -> String { self.fee_speed.duration() @@ -481,3 +496,34 @@ fn fee_rate_options_with_total_fee_is_equal( ) -> bool { lhs == rhs } + +#[cfg(test)] +mod tests { + use cove_util::format::NumberFormatter as _; + + use super::*; + + #[test] + fn fee_rate_formats_with_two_decimal_places_and_units() { + assert_eq!(FeeRate::from_sat_per_vb(0.0).sats_per_vbyte_string(), "0.00 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(1.0).sats_per_vbyte_string(), "1.00 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(1.25).sats_per_vbyte_string(), "1.25 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(12.345).sats_per_vbyte_string(), "12.35 sats/vbyte"); + } + + #[test] + fn fee_rate_options_delegate_to_fee_rate_formatting() { + let option = FeeRateOption::new(FeeSpeed::Fast, 3.2); + assert_eq!(option.sats_per_vbyte_string(), "3.20 sats/vbyte"); + + let option_with_total = FeeRateOptionWithTotalFee::new(option, Amount::from_sat(1_000)); + assert_eq!(option_with_total.sats_per_vbyte_string(), "3.20 sats/vbyte"); + } + + #[test] + fn grouped_integer_formatting_matches_platform_number_format() { + assert_eq!(0_u32.thousands_int(), "0"); + assert_eq!(1_u32.thousands_int(), "1"); + assert_eq!(1_000_000_u32.thousands_int(), "1,000,000"); + } +} diff --git a/rust/crates/cove-ur/src/crypto_hdkey.rs b/rust/crates/cove-ur/src/crypto_hdkey.rs index caf39fb43..797565d97 100644 --- a/rust/crates/cove-ur/src/crypto_hdkey.rs +++ b/rust/crates/cove-ur/src/crypto_hdkey.rs @@ -510,20 +510,14 @@ mod tests { // mainnet: m/84'/0'/0' let mut hdkey = CryptoHdkey::from_xpub(&xpub); - hdkey.origin = Some(CryptoKeypath::new( - vec![0x80000000 + 84, 0x80000000 + 0, 0x80000000 + 0], - None, - None, - )); + hdkey.origin = + Some(CryptoKeypath::new(vec![0x80000000 + 84, 0x80000000, 0x80000000], None, None)); assert_eq!(hdkey.infer_network(), bitcoin::Network::Bitcoin); // testnet: m/84'/1'/0' let mut hdkey = CryptoHdkey::from_xpub(&xpub); - hdkey.origin = Some(CryptoKeypath::new( - vec![0x80000000 + 84, 0x80000000 + 1, 0x80000000 + 0], - None, - None, - )); + hdkey.origin = + Some(CryptoKeypath::new(vec![0x80000000 + 84, 0x80000000 + 1, 0x80000000], None, None)); assert_eq!(hdkey.infer_network(), bitcoin::Network::Testnet); } @@ -583,7 +577,7 @@ mod tests { encoder.u32(IS_PRIVATE).unwrap(); encoder.bool(false).unwrap(); encoder.u32(KEY_DATA).unwrap(); - encoder.bytes(&vec![0x02; lengths::COMPRESSED_PUBKEY]).unwrap(); + encoder.bytes(&[0x02; lengths::COMPRESSED_PUBKEY]).unwrap(); let result = CryptoHdkey::from_cbor(&cbor); assert!(result.is_err()); @@ -630,7 +624,7 @@ mod tests { encoder.u32(IS_PRIVATE).unwrap(); encoder.bool(false).unwrap(); // public key encoder.u32(KEY_DATA).unwrap(); - encoder.bytes(&vec![0x02; lengths::PRIVATE_KEY]).unwrap(); // wrong length for public key + encoder.bytes(&[0x02; lengths::PRIVATE_KEY]).unwrap(); // wrong length for public key let result = CryptoHdkey::from_cbor(&cbor); assert!(result.is_err()); @@ -656,7 +650,7 @@ mod tests { encoder.u32(IS_PRIVATE).unwrap(); encoder.bool(false).unwrap(); // is_private encoder.u32(KEY_DATA).unwrap(); - encoder.bytes(&vec![0x02; lengths::COMPRESSED_PUBKEY]).unwrap(); // key_data (public) + encoder.bytes(&[0x02; lengths::COMPRESSED_PUBKEY]).unwrap(); // key_data (public) encoder.u32(99).unwrap(); // unknown key encoder.str("future field").unwrap(); diff --git a/rust/crates/cove-ur/src/keypath.rs b/rust/crates/cove-ur/src/keypath.rs index f5a2657f9..944b96ba3 100644 --- a/rust/crates/cove-ur/src/keypath.rs +++ b/rust/crates/cove-ur/src/keypath.rs @@ -114,8 +114,8 @@ mod tests { // m/44'/0'/0'/0/0 let components = vec![ 0x80000000 + 44, // 44' (hardened) - 0x80000000 + 0, // 0' (hardened) - 0x80000000 + 0, // 0' (hardened) + 0x80000000, // 0' (hardened) + 0x80000000, // 0' (hardened) 0, // 0 0, // 0 ]; @@ -148,8 +148,8 @@ mod tests { // m/84'/0'/0' let components = vec![ 0x80000000 + 84, // 84' (hardened) - 0x80000000 + 0, // 0' (hardened) - 0x80000000 + 0, // 0' (hardened) + 0x80000000, // 0' (hardened) + 0x80000000, // 0' (hardened) ]; let keypath = CryptoKeypath::new(components, None, None); assert_eq!(keypath.to_path_string(), "84h/0h/0h"); @@ -157,8 +157,8 @@ mod tests { // m/44'/0'/0'/0/0 (mixed hardened and non-hardened) let components = vec![ 0x80000000 + 44, // 44' (hardened) - 0x80000000 + 0, // 0' (hardened) - 0x80000000 + 0, // 0' (hardened) + 0x80000000, // 0' (hardened) + 0x80000000, // 0' (hardened) 0, // 0 0, // 0 ]; diff --git a/rust/src/app.rs b/rust/src/app.rs index c52c0fa29..41cc58523 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -22,6 +22,8 @@ use crate::{ keychain::{Keychain, KeychainError}, manager::cloud_backup_manager::{CLOUD_BACKUP_MANAGER, CloudBackupKeychain}, manager::deferred_dispatch::{DeferredDispatch, Dispatchable}, + manager::deferred_sender::SingleOrMany, + manager::reconcile_channel::ReconcileChannel, network::Network, node::{Node, client::NodeClient}, router::{ @@ -33,7 +35,6 @@ use crate::{ use cove_macros::impl_default_for; use cove_types::BlockSizeLast; use cove_util::ResultExt as _; -use flume::{Receiver, Sender}; use once_cell::sync::OnceCell; use parking_lot::RwLock; use reconcile::{AppStateReconcileMessage as AppMessage, FfiReconcile, Updater}; @@ -71,7 +72,7 @@ fn wallet_selection_loading_route(id: WalletId, next_route: Option) -> Ro #[derive(Clone, Debug)] pub struct App { state: Arc>, - update_receiver: Arc>, + reconcile: ReconcileChannel, } #[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] @@ -116,9 +117,9 @@ impl App { crate::bootstrap::ensure_storage_bootstrapped().expect("storage bootstrap failed"); // Set up the updater channel - let (sender, receiver): (Sender, Receiver) = flume::bounded(1000); + let reconcile = ReconcileChannel::new(1000); - Updater::init(sender); + Updater::init(reconcile.raw_sender()); let state = Arc::new(RwLock::new(AppState::new())); #[cfg(debug_assertions)] @@ -144,7 +145,7 @@ impl App { }); } - Self { update_receiver: Arc::new(receiver), state } + Self { reconcile, state } } /// Fetch global instance of the app, or create one if it doesn't exist @@ -297,11 +298,12 @@ impl App { } pub fn listen_for_updates(&self, updater: Box) { - let update_receiver = self.update_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = update_receiver.recv() { - updater.reconcile(field); + self.reconcile.listen(move |field| match field { + SingleOrMany::Single(message) => updater.reconcile(message), + SingleOrMany::Many(messages) => { + for message in messages { + updater.reconcile(message); + } } }); } diff --git a/rust/src/app/reconcile.rs b/rust/src/app/reconcile.rs index 30496b360..6e268a924 100644 --- a/rust/src/app/reconcile.rs +++ b/rust/src/app/reconcile.rs @@ -9,6 +9,7 @@ use crate::{ color_scheme::ColorSchemeSelection, fee_client::FeeResponse, fiat::{FiatCurrency, client::PriceResponse}, + manager::deferred_sender::SingleOrMany, network::Network, node::Node, router::Route, @@ -38,11 +39,11 @@ pub enum AppStateReconcileMessage { pub type Update = AppStateReconcileMessage; pub static UPDATER: OnceCell = OnceCell::new(); -pub struct Updater(pub Sender); +pub struct Updater(pub Sender>); impl Updater { /// Initialize global instance of the updater with a sender - pub fn init(sender: Sender) { + pub fn init(sender: Sender>) { UPDATER.get_or_init(|| Self(sender)); } @@ -54,7 +55,7 @@ impl Updater { return; }; - if let Err(error) = updater.0.send(message) { + if let Err(error) = updater.0.send(SingleOrMany::Single(message)) { tracing::error!("Failed to send update, frontend may be disconnected: {error}"); } } @@ -69,13 +70,15 @@ pub trait FfiReconcile: Send + Sync + 'static { #[cfg(test)] pub(crate) mod test_support { use super::*; + use crate::manager::reconcile_channel::ReconcileChannel; pub(crate) fn init_noop_updater() { - let (sender, receiver) = flume::bounded(1000); + let channel = ReconcileChannel::::new(1000); + let receiver = channel.receiver(); std::thread::Builder::new() .name("noop-app-updater-drain".into()) .spawn(move || while receiver.recv().is_ok() {}) .expect("spawn noop app updater drain"); - Updater::init(sender); + Updater::init(channel.raw_sender()); } } diff --git a/rust/src/database/cloud_backup.rs b/rust/src/database/cloud_backup.rs index 0ad83de55..5c9935613 100644 --- a/rust/src/database/cloud_backup.rs +++ b/rust/src/database/cloud_backup.rs @@ -14,7 +14,7 @@ mod tables; pub(crate) use compat::ensure_table_type_compatibility; pub use state::{ CloudBackupRecordKey, CloudBlobConfirmedState, CloudBlobDirtyState, CloudBlobFailedState, - CloudBlobFailureIssue, CloudBlobUploadedPendingConfirmationState, CloudBlobUploadingState, + CloudBlobUploadedPendingConfirmationState, CloudBlobUploadingState, CloudStorageIssue, PersistedBackupSyncState, PersistedBackupVerificationState, PersistedCloudBackupState, PersistedCloudBackupStatus, PersistedCloudBlobState, PersistedCloudBlobSyncState, PersistedConfiguredCloudBackup, PersistedDeepVerificationReport, PersistedDisablingCloudBackup, diff --git a/rust/src/database/cloud_backup/compatibility.rs b/rust/src/database/cloud_backup/compatibility.rs index ccc9bbf89..3c10a0485 100644 --- a/rust/src/database/cloud_backup/compatibility.rs +++ b/rust/src/database/cloud_backup/compatibility.rs @@ -420,10 +420,10 @@ mod tests { use cove_cspp::backup_data::MASTER_KEY_RECORD_ID; use super::super::{ - CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, PersistedBackupSyncState, - PersistedBackupVerificationState, PersistedCloudBackupState, PersistedCloudBackupStatus, - PersistedCloudBlobState, PersistedCloudBlobSyncState, PersistedConfiguredCloudBackup, - PersistedPasskeyState, PersistedPendingVerificationUpload, + CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, CloudStorageIssue, + PersistedBackupSyncState, PersistedBackupVerificationState, PersistedCloudBackupState, + PersistedCloudBackupStatus, PersistedCloudBlobState, PersistedCloudBlobSyncState, + PersistedConfiguredCloudBackup, PersistedPasskeyState, PersistedPendingVerificationUpload, }; fn configured_state( @@ -852,6 +852,21 @@ mod tests { assert_eq!(failed_state.issue, None); } + #[test] + fn cloud_storage_issue_preserves_cloud_blob_failure_issue_json() { + let cases = [ + (CloudStorageIssue::AuthorizationRequired, "\"AuthorizationRequired\""), + (CloudStorageIssue::Offline, "\"Offline\""), + (CloudStorageIssue::Unavailable, "\"Unavailable\""), + (CloudStorageIssue::NotFound, "\"NotFound\""), + (CloudStorageIssue::QuotaExceeded, "\"QuotaExceeded\""), + ]; + + for (issue, expected_json) in cases { + assert_eq!(serde_json::to_string(&issue).unwrap(), expected_json); + } + } + #[test] fn pending_verification_upload_accepts_legacy_plain_wallet() { let upload: PersistedPendingVerificationUpload = diff --git a/rust/src/database/cloud_backup/state.rs b/rust/src/database/cloud_backup/state.rs index 97c53e192..711d4c3d9 100644 --- a/rust/src/database/cloud_backup/state.rs +++ b/rust/src/database/cloud_backup/state.rs @@ -572,15 +572,25 @@ pub struct CloudBlobFailedState { pub retryable: bool, pub error: String, #[serde(default)] - pub issue: Option, + pub issue: Option, pub failed_at: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CloudBlobFailureIssue { +pub enum CloudStorageIssue { AuthorizationRequired, Offline, Unavailable, NotFound, QuotaExceeded, + Other, +} + +impl CloudStorageIssue { + pub(crate) fn persistable(self) -> Option { + match self { + Self::Other => None, + issue => Some(issue), + } + } } diff --git a/rust/src/database/key/outpoint.rs b/rust/src/database/key/outpoint.rs index 95c15071a..d6f6257fe 100644 --- a/rust/src/database/key/outpoint.rs +++ b/rust/src/database/key/outpoint.rs @@ -1,4 +1,3 @@ -use bitcoin::hashes::serde_macros::serde_details::SerdeHash; use std::cmp::Ordering; use zerocopy::{FromBytes, Immutable, IntoBytes}; @@ -25,11 +24,6 @@ impl OutPointKey { pub fn new(id: impl AsRef<[u8; 32]>, index: u32) -> Self { Self { id: *id.as_ref(), index } } - - #[allow(dead_code)] - pub fn id(&self) -> bitcoin::Txid { - bitcoin::Txid::from_slice_delegated(&self.id).expect("id is 32 bytes") - } } impl redb::Key for OutPointKey { @@ -74,29 +68,3 @@ impl redb::Value for OutPointKey { redb::TypeName::new("OutPointKey::bitcoin::OutPoint") } } - -#[cfg(test)] -mod tests { - use std::str::FromStr as _; - - use super::*; - - #[test] - fn test_in_out_id() { - let id = OutPointKey::new( - bitcoin::Txid::from_str( - "d9f76c1c2338eb2010255c16e7cbdf72c1263e81c08a465b5d1d76a36d9980dc", - ) - .unwrap(), - 0, - ); - - assert_eq!( - id.id(), - bitcoin::Txid::from_str( - "d9f76c1c2338eb2010255c16e7cbdf72c1263e81c08a465b5d1d76a36d9980dc" - ) - .unwrap() - ); - } -} diff --git a/rust/src/manager.rs b/rust/src/manager.rs index 623ed4763..288f8b316 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -7,5 +7,6 @@ pub mod deferred_sender; pub mod import_wallet_manager; pub mod onboarding_manager; pub mod pending_wallet_manager; +pub mod reconcile_channel; pub mod send_flow_manager; pub mod wallet_manager; diff --git a/rust/src/manager/auth_manager.rs b/rust/src/manager/auth_manager.rs index 3ee53328f..7c264f805 100644 --- a/rust/src/manager/auth_manager.rs +++ b/rust/src/manager/auth_manager.rs @@ -1,8 +1,5 @@ use std::sync::{Arc, LazyLock}; -use cove_macros::impl_default_for; -use flume::{Receiver, Sender}; -use parking_lot::RwLock; use tap::TapFallible as _; use tracing::{debug, error}; @@ -12,6 +9,7 @@ use crate::{ app::reconcile::{AppStateReconcileMessage, Updater}, auth::{AuthPin, AuthType}, database::{self, Database}, + manager::{deferred_sender::SingleOrMany, reconcile_channel::ReconcileChannel}, wallet::metadata::WalletMode, }; @@ -104,15 +102,9 @@ pub enum SecuritySettingsResult { #[derive(Clone, Debug, uniffi::Object)] pub struct RustAuthManager { - #[allow(dead_code)] - pub state: Arc>, - pub reconciler: Sender, - pub reconcile_receiver: Arc>, + pub reconciler: ReconcileChannel, } -#[derive(Clone, Debug, uniffi::Record)] -pub struct AuthManagerState {} - type Action = AuthManagerAction; type Result = std::result::Result; @@ -152,14 +144,7 @@ pub enum TrickPinError { impl RustAuthManager { fn init() -> Arc { - let (sender, receiver) = flume::bounded(1000); - - Self { - state: Arc::new(RwLock::new(AuthManagerState::new())), - reconciler: sender, - reconcile_receiver: Arc::new(receiver), - } - .into() + Self { reconciler: ReconcileChannel::new(1000) }.into() } } @@ -171,12 +156,12 @@ impl RustAuthManager { } pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - // call the reconcile method on the frontend - reconciler.reconcile(field); + self.reconciler.listen(move |field| match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => { + for message in messages { + reconciler.reconcile(message); + } } }); } @@ -345,9 +330,7 @@ impl RustAuthManager { } fn send(&self, message: Message) { - if let Err(error) = self.reconciler.send(message) { - error!("unable to send message: {error:?}"); - } + self.reconciler.send_sync(message); } /// Action from the frontend to change the state of the view model @@ -554,13 +537,6 @@ impl RustAuthManager { } } -impl_default_for!(AuthManagerState); -impl AuthManagerState { - pub const fn new() -> Self { - Self {} - } -} - #[uniffi::export(callback_interface)] pub trait AuthManagerReconciler: Send + Sync + std::fmt::Debug + 'static { /// Tells the frontend to reconcile the manager changes diff --git a/rust/src/manager/cloud_backup_manager.rs b/rust/src/manager/cloud_backup_manager.rs index 2c84b03cf..ad0ff8527 100644 --- a/rust/src/manager/cloud_backup_manager.rs +++ b/rust/src/manager/cloud_backup_manager.rs @@ -4,6 +4,7 @@ mod catastrophic_recovery; mod cloud_inventory; mod cspp_exports; mod detail; +mod dto; mod error; mod keychain; mod model; @@ -25,44 +26,56 @@ use std::sync::{Arc, LazyLock}; use act_zero::{Addr, call, send}; use cove_device::cloud_storage::{CloudStorageClient, CloudSyncHealth}; use cove_tokio::task::spawn_actor; -use cove_util::ResultExt as _; -use flume::{Receiver, Sender}; +use flume::Receiver; use parking_lot::RwLock; use tracing::{error, info, warn}; -use cove_types::network::Network; - use crate::database::Database; -use crate::database::cloud_backup::{ - CloudBlobFailureIssue, PersistedCloudBackupState, PersistedCloudBackupStatus, -}; -use crate::wallet::metadata::{WalletId, WalletMode as LocalWalletMode, WalletType}; +pub(crate) use crate::database::cloud_backup::CloudStorageIssue; +use crate::database::cloud_backup::{PersistedCloudBackupState, PersistedCloudBackupStatus}; +use crate::manager::reconcile_channel::ReconcileChannel; +use crate::wallet::metadata::{WalletId, WalletMode as LocalWalletMode}; pub(crate) use self::actors::CloudBackupRestoreEvent; use self::actors::{ - CloudBackupOperation, CloudBackupSupervisor, CloudBackupUploadedWallet, - CloudBackupWalletCountRefresh, CloudBackupWriteBlocker, CloudBackupWriteClient, - CloudBackupWriteCompletion, CloudBackupWriteResultReceiver, CloudBackupWriteSupervisor, + CloudBackupSupervisor, CloudBackupUploadedWallet, CloudBackupWalletCountRefresh, + CloudBackupWriteBlocker, CloudBackupWriteClient, CloudBackupWriteCompletion, + CloudBackupWriteSupervisor, }; pub(crate) use self::detail::{ CloudBackupCloudOnlyFetchOutcome, CloudBackupCloudOnlyOperationWarning, - CloudBackupCloudOnlyWalletOutcome, CloudBackupDetailOutcome, CloudBackupEnableOutcome, - CloudBackupOtherBackupsOutcome, CloudBackupRecoveryOutcome, CloudBackupRestoreOutcome, - CloudBackupSyncOutcome, CloudBackupVerificationOutcome, + CloudBackupCloudOnlyWalletOutcome, CloudBackupDetailOutcome, CloudBackupOtherBackupsOutcome, + CloudBackupRestoreOutcome, }; pub use self::detail::{ CloudBackupVerificationPresentation, CloudBackupVerificationReason, CloudBackupVerificationSource, CloudOnlyOperation, CloudOnlyState, +}; +pub(crate) use self::detail::{ PendingUploadVerificationState, RecoveryAction, RecoveryState, SyncState, VerificationState, }; +pub use self::dto::{ + CloudBackupDetail, CloudBackupEnableContext, CloudBackupEnablePromptChoice, + CloudBackupOtherBackupsState, CloudBackupOtherBackupsSummary, CloudBackupPasskeyChoiceIntent, + CloudBackupPasskeyHint, CloudBackupProgress, CloudBackupRestoreReport, CloudBackupRetryAction, + CloudBackupRootPrompt, CloudBackupSettingsRowStatus, CloudBackupVerificationMetadata, + CloudBackupWalletItem, CloudBackupWalletStatus, DeepVerificationFailure, + DeepVerificationReport, DeepVerificationResult, OtherBackupsOperation, RecordId, + SavedPasskeyConfirmationMode, +}; +pub type CloudBackupManagerAction = self::dto::CloudBackupManagerAction; +pub type CloudBackupState = self::dto::CloudBackupState; pub(crate) use self::error::{ - BlockingCloudStep, CloudBackupError, CloudStorageIssue, blocking_cloud_error, - is_connectivity_related_issue, offline_error_for_step, + BlockingCloudStep, CloudBackupError, blocking_cloud_error, is_connectivity_related_issue, + offline_error_for_step, }; pub(crate) use self::keychain::CloudBackupKeychain; -use self::model::{ - CloudBackupAcceptedEnablePrompt, CloudBackupExclusiveOperation, - CloudBackupExclusiveOperationClaim, CloudBackupStateReducer, CloudBackupStateReducerEvent, +#[cfg(test)] +pub(crate) use self::model::test_support; +pub(crate) use self::model::{ + CloudBackupAcceptedEnablePrompt, CloudBackupDetailResult, CloudBackupDisableOutcome, + CloudBackupEnableState, CloudBackupExclusiveOperation, CloudBackupExclusiveOperationClaim, + CloudBackupStateReducer, CloudBackupStateReducerEvent, CloudBackupStatus, }; pub use self::model::{CloudBackupLifecycle, CloudBackupRestoreFlow}; pub(crate) use self::ops::{ @@ -98,7 +111,7 @@ pub(crate) const CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE: &str = concat!( "Cloud Backup local state could not be read. ", "Contact support before changing Cloud Backup settings." ); -pub(super) const CLOUD_BACKUP_IO_CONCURRENCY: usize = 4; +pub(crate) const CLOUD_BACKUP_IO_CONCURRENCY: usize = 4; type Message = CloudBackupReconcileMessage; pub(crate) fn current_timestamp() -> u64 { @@ -108,100 +121,8 @@ pub(crate) fn current_timestamp() -> u64 { pub static CLOUD_BACKUP_MANAGER: LazyLock> = LazyLock::new(RustCloudBackupManager::init); -/// Runtime cloud backup status persisted or projected for compatibility paths -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub enum CloudBackupStatus { - Disabled, - Disabling, - Enabling, - Restoring, - Enabled, - PasskeyMissing, - UnsupportedPasskeyProvider, - Error(String), -} - -/// Shared settings row state projected for Swift and Kotlin presentation -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupSettingsRowStatus { - Disabled, - Disabling, - SettingUp, - Restoring, - Active, - PasskeyMissing, - PasskeyProviderUnsupported, - Unverified, - Confirming, - VerificationRecommended, - CheckingSync, - Syncing, - NoFiles, - DriveUnavailable, - Error(String), - AuthorizationRequired(String), -} - -/// Whether saved passkey confirmation was user-triggered or flow-triggered -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum SavedPasskeyConfirmationMode { - Automatic, - Manual, -} - -/// Context carried through enable so prompts and verification attribution stay stable -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Record)] -pub struct CloudBackupEnableContext { - pub saved_passkey_confirmation: SavedPasskeyConfirmationMode, - pub verification_source: CloudBackupVerificationSource, -} - -impl CloudBackupEnableContext { - pub(crate) fn settings_manual() -> Self { - Self { - saved_passkey_confirmation: SavedPasskeyConfirmationMode::Manual, - verification_source: CloudBackupVerificationSource::Settings, - } - } -} - -/// Internal enable status before projection into the public lifecycle -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub enum CloudBackupEnableState { - Idle, - CreatingPasskey, - WaitingForPasskeyAvailability, - AwaitingSavedPasskeyConfirmation(SavedPasskeyConfirmationMode), - ConfirmingSavedPasskey, - UploadingBackup, -} - -/// Prompt intent for choosing between an existing passkey and a new one -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupPasskeyChoiceIntent { - Enable(CloudBackupEnableContext, Option), - RepairPasskey, -} - -/// User selection for the currently visible enable prompt -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupEnablePromptChoice { - UseExisting, - CreateNew, -} - -/// Root-level prompt the UI should show for the current cloud backup state -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupRootPrompt { - None, - ExistingBackupFound(CloudBackupEnableContext, Option), - PasskeyChoice(CloudBackupPasskeyChoiceIntent), - MissingPasskeyReminder, - Verification, -} - /// User intent routed from Swift or Kotlin into the Rust cloud backup manager -#[derive(Debug, Clone, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum CloudBackupManagerAction { EnableCloudBackup(CloudBackupEnableContext), EnableCloudBackupForceNew(CloudBackupEnableContext), @@ -233,173 +154,14 @@ pub enum CloudBackupManagerAction { AcceptEnablePrompt(CloudBackupEnablePromptChoice), } -/// Result of a disable attempt after the supervisor resolves remote and local work -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CloudBackupDisableOutcome { - Started, - ReturnedToIdle, - Failed { message: String, can_keep_enabled: bool }, -} - -/// Restore summary shown after cloud backup onboarding restore completes -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CloudBackupRestoreReport { - pub wallets_restored: u32, - pub wallets_failed: u32, - pub failed_wallet_errors: Vec, - pub labels_failed_wallet_names: Vec, - pub labels_failed_errors: Vec, -} - -/// Completed and total counts for long-running cloud backup work -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Record)] -pub struct CloudBackupProgress { - pub completed: u32, - pub total: u32, -} - -/// Cloud backup record identifier exposed through UniFFI as an opaque string -#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Into)] -pub struct RecordId(String); - -uniffi::custom_newtype!(RecordId, String); - -/// Per-wallet cloud backup sync state shown in backup detail -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupWalletStatus { - Dirty, - Uploading, - UploadedPendingConfirmation, - Confirmed, - Failed, - DeletedFromDevice, - UnsupportedVersion, - RemoteStateUnknown, -} - -/// Wallet row in cloud backup detail, combining local wallet metadata and sync state -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Record)] -pub struct CloudBackupWalletItem { - pub name: String, - pub network: Option, - pub wallet_mode: Option, - pub wallet_type: Option, - pub fingerprint: Option, - pub label_count: Option, - pub backup_updated_at: Option, - pub sync_status: CloudBackupWalletStatus, - /// Deterministic cloud record ID for the wallet backup represented by this item - pub record_id: String, -} - -/// Remote detail fetch result that keeps access errors distinguishable from failed detail state -#[derive(Debug)] -pub enum CloudBackupDetailResult { - Success(CloudBackupDetail), - AccessError(CloudBackupError), -} - -/// Backup detail grouped by local wallet sync status and remote-only inventory -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CloudBackupDetail { - pub last_sync: Option, - pub up_to_date: Vec, - pub needs_sync: Vec, - /// Number of wallets in the cloud that aren't on this device - pub cloud_only_count: u32, - pub other_backups: CloudBackupOtherBackupsState, -} - -/// Summary state for backup namespaces that do not match the active device -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] -pub enum CloudBackupOtherBackupsState { - Loaded { summary: CloudBackupOtherBackupsSummary }, - LoadFailed { error: String }, -} - -/// Aggregate count of recoverable backup data in other namespaces -#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)] -pub struct CloudBackupOtherBackupsSummary { - pub namespace_count: u32, - pub wallet_count: u32, - pub passkey_hints: Vec, -} - -/// User-facing passkey hint that avoids exposing credential bytes -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Record)] -pub struct CloudBackupPasskeyHint { - pub provider_name: Option, - pub name_suffix: String, - pub registered_at: u64, -} - -impl CloudBackupPasskeyHint { - pub(crate) fn from_provider_hint(hint: &cove_cspp::backup_data::PasskeyProviderHint) -> Self { - Self { - provider_name: hint.known_provider().map(|provider| provider.display_name().into()), - name_suffix: hint.name_suffix.clone(), - registered_at: hint.registered_at, - } - } -} - -/// Operation state for recovering or deleting other backup namespaces -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] -pub enum OtherBackupsOperation { - Idle, - Recovering, - Recovered { wallets_restored: u32, wallets_failed: u32, failed_wallet_errors: Vec }, - Deleting, - Deleted, - Failed { error: String }, -} - -/// Outcome of deep verification before projection into UI state -#[derive(Debug, Clone, uniffi::Enum)] -pub enum DeepVerificationResult { - Verified(DeepVerificationReport), - AwaitingUploadConfirmation(DeepVerificationReport), - PasskeyConfirmed(Option), - PasskeyMissing(Option), - UserCancelled(Option), - NotEnabled, - Failed(DeepVerificationFailure), -} - -/// Counts and repairs observed during a deep verification pass -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct DeepVerificationReport { - /// Cloud master key PRF wrapping was repaired - pub master_key_wrapper_repaired: bool, - /// Local keychain was repaired from verified cloud master key - pub local_master_key_repaired: bool, - /// credential_id was recovered via discoverable auth - pub credential_recovered: bool, - pub wallets_verified: u32, - pub wallets_failed: u32, - /// Wallet backups with unsupported version (newer format, skipped) - pub wallets_unsupported: u32, - /// May be None if wallet list was missing but master key verified - pub detail: Option, -} - -/// Persisted verification metadata projected into prompts and detail state -#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupVerificationMetadata { - NotConfigured, - ConfiguredNeverVerified, - Verified(u64), - NeedsVerification, -} - /// Trust failure that tells the UI which recovery path is valid -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum DeepVerificationFailure { /// Transient iCloud/network/passkey error — safe to retry Retry { message: String, detail: Option, - retry_context: Option, + retry_action: Option, }, /// Manifest missing, master key verified intact — recreate from local wallets RecreateManifest { message: String, warning: String, detail: Option }, @@ -409,97 +171,13 @@ pub enum DeepVerificationFailure { UnsupportedVersion { message: String, detail: Option }, } -/// Retry issue category for a user-visible verification retry -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupRetryIssue { - Connectivity, -} - -/// Retry action the UI should dispatch for a retryable verification failure -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum CloudBackupRetryAction { - Verify, - VerifyDiscoverable, -} - -/// Retry instruction attached to a retryable deep verification failure -#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Record)] -pub struct CloudBackupRetryContext { - pub issue: CloudBackupRetryIssue, - pub action: CloudBackupRetryAction, -} - /// Top-level state snapshot exposed to platform managers -#[derive(Debug, Clone, uniffi::Record)] +#[uniffi::remote(Record)] pub struct CloudBackupState { pub lifecycle: CloudBackupLifecycle, pub settings_row_status: CloudBackupSettingsRowStatus, } -impl Default for CloudBackupState { - fn default() -> Self { - Self { - lifecycle: CloudBackupLifecycle::Disabled, - settings_row_status: CloudBackupSettingsRowStatus::Disabled, - } - } -} - -#[cfg(test)] -pub(crate) mod test_support { - use super::*; - - #[derive(Debug, Clone)] - pub(crate) struct CloudBackupModelSnapshot { - pub(crate) root_prompt: CloudBackupRootPrompt, - pub(crate) status: CloudBackupStatus, - pub(crate) sync_health: CloudSyncHealth, - pub(crate) progress: Option, - pub(crate) restore_progress: Option, - pub(crate) enable_state: CloudBackupEnableState, - pub(crate) pending_upload_verification: PendingUploadVerificationState, - pub(crate) verification_presentation: CloudBackupVerificationPresentation, - pub(crate) detail: Option, - pub(crate) verification: VerificationState, - } - - impl Default for CloudBackupModelSnapshot { - fn default() -> Self { - Self { - root_prompt: CloudBackupRootPrompt::None, - status: CloudBackupStatus::Disabled, - sync_health: CloudSyncHealth::Unknown, - progress: None, - restore_progress: None, - enable_state: CloudBackupEnableState::Idle, - pending_upload_verification: PendingUploadVerificationState::Idle, - verification_presentation: CloudBackupVerificationPresentation::Hidden { - source: None, - }, - detail: None, - verification: VerificationState::Idle, - } - } - } -} - -impl From<&PersistedCloudBackupState> for CloudBackupVerificationMetadata { - fn from(db_state: &PersistedCloudBackupState) -> Self { - if db_state.is_unverified() { - return Self::NeedsVerification; - } - - if !db_state.is_configured() { - return Self::NotConfigured; - } - - match db_state.last_verified_at() { - Some(last_verified_at) => Self::Verified(last_verified_at), - None => Self::ConfiguredNeverVerified, - } - } -} - #[uniffi::export] impl DeepVerificationFailure { pub fn message(&self) -> String { @@ -512,66 +190,18 @@ impl DeepVerificationFailure { } } -impl DeepVerificationFailure { - pub(crate) fn retry( - message: impl Into, - detail: Option, - retry_context: Option, - ) -> Self { - Self::Retry { message: message.into(), detail, retry_context } - } - - pub(crate) fn detail(&self) -> Option<&CloudBackupDetail> { - match self { - Self::Retry { detail, .. } - | Self::RecreateManifest { detail, .. } - | Self::ReinitializeBackup { detail, .. } - | Self::UnsupportedVersion { detail, .. } => detail.as_ref(), - } - } - - pub(crate) fn is_connectivity_retry(&self) -> bool { - matches!( - self, - Self::Retry { - retry_context: Some(CloudBackupRetryContext { - issue: CloudBackupRetryIssue::Connectivity, - .. - }), - .. - } - ) - } - - pub(crate) fn connectivity_retry_action(&self) -> Option { - match self { - Self::Retry { - retry_context: - Some(CloudBackupRetryContext { issue: CloudBackupRetryIssue::Connectivity, action }), - .. - } => Some(*action), - _ => None, - } - } -} - -impl CloudBackupRetryContext { - pub(crate) fn connectivity(action: CloudBackupRetryAction) -> Self { - Self { issue: CloudBackupRetryIssue::Connectivity, action } - } -} - -impl CloudBackupDetailResult { - pub(crate) fn is_connectivity_access_error(&self) -> bool { - matches!(self, Self::AccessError(error) if is_connectivity_related_issue(error)) - } -} - +#[cfg_attr( + doc, + doc = "Single entry point for Cloud Backup orchestration, UniFFI calls, and reconcile wiring", + doc = "", + doc = "The intentional non-UI reach-ins are wallet-set notifications from", + doc = "`pending_wallet_manager.rs` and `import_wallet_manager.rs`, plus the import", + doc = "wallet backup-change notification that triggers re-verification." +)] #[derive(Clone, Debug, uniffi::Object)] pub struct RustCloudBackupManager { - pub state: Arc>, - pub reconciler: Sender, - pub reconcile_receiver: Arc>, + pub(crate) state: Arc>, + pub(crate) reconciler: ReconcileChannel, cloud_only_detail_snapshot: Arc>>, cloud_writes: Addr, pub(crate) supervisor: Addr, @@ -673,15 +303,6 @@ impl RustCloudBackupManager { Ok(()) } - async fn await_cloud_backup_write( - receiver: CloudBackupWriteResultReceiver, - ) -> Result { - receiver - .await - .map_err_prefix("wait for cloud backup write supervisor", CloudBackupError::Internal)? - .into_result() - } - pub(crate) async fn upload_cloud_wallet_backup( &self, cloud: CloudStorageClient, @@ -702,14 +323,9 @@ impl RustCloudBackupManager { data: Vec, completion: CloudBackupWriteCompletion, ) -> Result<(), CloudBackupError> { - let receiver = - call!(self.cloud_writes.upload_wallet_backup_with_completion( - cloud, namespace, record_id, data, completion - )) + CloudBackupWriteClient::new(self.cloud_writes.clone()) + .upload_wallet_backup_with_completion(cloud, namespace, record_id, data, completion) .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; - - Self::await_cloud_backup_write(receiver).await } pub(crate) async fn complete_cloud_wallet_upload_batch( @@ -719,31 +335,15 @@ impl RustCloudBackupManager { uploaded_wallets: Vec, count_refresh: CloudBackupWalletCountRefresh, ) -> Result<(), CloudBackupError> { - let receiver = call!(self.cloud_writes.complete_uploaded_wallet_batch( - cloud, - namespace_id, - uploaded_wallets, - count_refresh - )) - .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; - - Self::await_cloud_backup_write(receiver).await + CloudBackupWriteClient::new(self.cloud_writes.clone()) + .complete_uploaded_wallet_batch(cloud, namespace_id, uploaded_wallets, count_refresh) + .await } - pub(crate) fn cloud_blob_failure_issue( + pub(crate) fn persistable_cloud_storage_issue( issue: CloudStorageIssue, - ) -> Option { - match issue { - CloudStorageIssue::AuthorizationRequired => { - Some(CloudBlobFailureIssue::AuthorizationRequired) - } - CloudStorageIssue::Offline => Some(CloudBlobFailureIssue::Offline), - CloudStorageIssue::Unavailable => Some(CloudBlobFailureIssue::Unavailable), - CloudStorageIssue::NotFound => Some(CloudBlobFailureIssue::NotFound), - CloudStorageIssue::QuotaExceeded => Some(CloudBlobFailureIssue::QuotaExceeded), - CloudStorageIssue::Other => None, - } + ) -> Option { + issue.persistable() } pub(crate) fn connection_status(&self) -> ConnectivityStatus { @@ -770,14 +370,11 @@ impl RustCloudBackupManager { } fn init() -> Arc { - let (sender, receiver) = flume::bounded(1000); - let manager = Arc::new_cyclic(|manager| { let cloud_writes = spawn_actor(CloudBackupWriteSupervisor::new(manager.clone())); Self { state: Arc::new(RwLock::new(CloudBackupStateReducer::default())), - reconciler: sender, - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(1000), cloud_only_detail_snapshot: Arc::new(RwLock::new(None)), cloud_writes: cloud_writes.clone(), supervisor: spawn_actor(CloudBackupSupervisor::new(manager.clone(), cloud_writes)), @@ -891,42 +488,12 @@ impl RustCloudBackupManager { self.apply_model_event(CloudBackupStateReducerEvent::MissingPasskeyPromptDismissed); } - pub(crate) fn apply_enable_outcome(&self, outcome: CloudBackupEnableOutcome) { - match outcome { - CloudBackupEnableOutcome::ProgressCleared => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableProgressReported(None)); - } - CloudBackupEnableOutcome::ReturnedToIdle => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::Idle, - )); - } - CloudBackupEnableOutcome::CreatingPasskey => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::CreatingPasskey, - )); - } - CloudBackupEnableOutcome::WaitingForPasskeyAvailability => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::WaitingForPasskeyAvailability, - )); - } - CloudBackupEnableOutcome::UploadingBackup => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::UploadingBackup, - )); - } - CloudBackupEnableOutcome::ConfirmingSavedPasskey => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::ConfirmingSavedPasskey, - )); - } - CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation(mode) => { - self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced( - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation(mode), - )); - } - } + pub(crate) fn clear_enable_progress_report(&self) { + self.apply_model_event(CloudBackupStateReducerEvent::EnableProgressReported(None)); + } + + pub(crate) fn apply_enable_state(&self, enable_state: CloudBackupEnableState) { + self.apply_model_event(CloudBackupStateReducerEvent::EnableFlowAdvanced(enable_state)); } pub(crate) fn apply_restore_outcome(&self, outcome: CloudBackupRestoreOutcome) { @@ -944,16 +511,7 @@ impl RustCloudBackupManager { send!(self.supervisor.request_sync_health_refresh()); } - pub(crate) fn apply_verification_outcome(&self, outcome: CloudBackupVerificationOutcome) { - let verification = match outcome { - CloudBackupVerificationOutcome::Idle => VerificationState::Idle, - CloudBackupVerificationOutcome::Started => VerificationState::Verifying, - CloudBackupVerificationOutcome::Verified(report) => VerificationState::Verified(report), - CloudBackupVerificationOutcome::PasskeyConfirmed => VerificationState::PasskeyConfirmed, - CloudBackupVerificationOutcome::Failed(failure) => VerificationState::Failed(failure), - CloudBackupVerificationOutcome::Cancelled => VerificationState::Cancelled, - }; - + pub(crate) fn apply_verification_state(&self, verification: VerificationState) { if matches!( verification, VerificationState::Idle | VerificationState::Failed(_) | VerificationState::Cancelled @@ -966,25 +524,11 @@ impl RustCloudBackupManager { )); } - pub(crate) fn apply_sync_outcome(&self, outcome: CloudBackupSyncOutcome) { - let sync = match outcome { - CloudBackupSyncOutcome::Started => SyncState::Syncing, - CloudBackupSyncOutcome::Completed => SyncState::Idle, - CloudBackupSyncOutcome::Failed(error) => SyncState::Failed(error), - }; - + pub(crate) fn apply_sync_state(&self, sync: SyncState) { self.apply_model_event(CloudBackupStateReducerEvent::SyncStateResolved(sync)); } - pub(crate) fn apply_recovery_outcome(&self, outcome: CloudBackupRecoveryOutcome) { - let recovery = match outcome { - CloudBackupRecoveryOutcome::Idle => RecoveryState::Idle, - CloudBackupRecoveryOutcome::Started(action) => RecoveryState::Recovering(action), - CloudBackupRecoveryOutcome::Failed { action, error } => { - RecoveryState::Failed { action, error } - } - }; - + pub(crate) fn apply_recovery_state(&self, recovery: RecoveryState) { if !matches!(recovery, RecoveryState::Idle) { self.clear_runtime_passkey_authorization(); } @@ -1009,15 +553,15 @@ impl RustCloudBackupManager { } self.clear_prompt_state(); - self.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + self.clear_enable_progress_report(); self.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); self.observe_sync_health(CloudSyncHealth::Unknown); - self.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + self.apply_enable_state(CloudBackupEnableState::Idle); self.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); self.apply_detail_outcome(CloudBackupDetailOutcome::Cleared); - self.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - self.apply_sync_outcome(CloudBackupSyncOutcome::Completed); - self.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + self.apply_verification_state(VerificationState::Idle); + self.apply_sync_state(SyncState::Idle); + self.apply_recovery_state(RecoveryState::Idle); self.apply_cloud_only_fetch_outcome(CloudBackupCloudOnlyFetchOutcome::Reset); self.apply_other_backups_outcome(CloudBackupOtherBackupsOutcome::Idle); self.reconcile_runtime_status(CloudBackupStatus::Disabled); @@ -1031,7 +575,7 @@ impl RustCloudBackupManager { Database::global() .cloud_backup_state .set(state) - .map_err(|error| CloudBackupError::Internal(format!("{context}: {error}")))?; + .map_err(|source| CloudBackupError::internal_context(context, source))?; self.reconcile_runtime_status(Self::runtime_status_for(state)); self.refresh_persisted_flags(); @@ -1074,11 +618,7 @@ impl RustCloudBackupManager { completion: PendingVerificationCompletion, source: CloudBackupVerificationSource, ) { - if let Some(detail) = completion.report().detail.clone() { - self.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(detail)); - } - - let persisted_completion = completion.persisted(); + let persisted_completion = completion.clone(); let mut state = Self::load_persisted_state(); state.replace_pending_verification_completion(persisted_completion); @@ -1097,10 +637,7 @@ impl RustCloudBackupManager { } pub(crate) fn pending_verification_completion(&self) -> Option { - Self::load_persisted_state() - .pending_verification_completion() - .cloned() - .map(PendingVerificationCompletion::from_persisted) + Self::load_persisted_state().pending_verification_completion().cloned() } pub(crate) fn clear_pending_verification_completion(&self) { @@ -1127,9 +664,9 @@ impl RustCloudBackupManager { error: &CloudBackupError, ) { self.project_exclusive_operation_finished(claim); - self.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + self.clear_enable_progress_report(); self.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); - self.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + self.apply_enable_state(CloudBackupEnableState::Idle); self.reconcile_runtime_status(Self::status_for_operation_error(error)); } } @@ -1261,15 +798,15 @@ impl RustCloudBackupManager { self.clear_pending_verification_completion(); self.clear_prompt_state(); - self.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + self.clear_enable_progress_report(); self.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); self.refresh_persisted_flags(); - self.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + self.apply_enable_state(CloudBackupEnableState::Idle); self.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); self.apply_detail_outcome(CloudBackupDetailOutcome::Cleared); - self.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - self.apply_sync_outcome(CloudBackupSyncOutcome::Completed); - self.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + self.apply_verification_state(VerificationState::Idle); + self.apply_sync_state(SyncState::Idle); + self.apply_recovery_state(RecoveryState::Idle); self.apply_cloud_only_fetch_outcome(CloudBackupCloudOnlyFetchOutcome::Reset); self.apply_other_backups_outcome(CloudBackupOtherBackupsOutcome::Idle); self.reconcile_runtime_status(CloudBackupStatus::Disabled); @@ -1301,21 +838,19 @@ impl RustCloudBackupManager { impl RustCloudBackupManager { pub(crate) fn enable_cloud_backup(&self, context: CloudBackupEnableContext) { - send!(self.supervisor.start_operation(CloudBackupOperation::Enable(context), None)); + send!(self.supervisor.start_enable_operation(context)); } pub(crate) fn enable_cloud_backup_force_new(&self, context: CloudBackupEnableContext) { - send!(self.supervisor.start_operation(CloudBackupOperation::EnableForceNew(context), None)); + send!(self.supervisor.start_enable_force_new_operation(context)); } pub(crate) fn enable_cloud_backup_no_discovery(&self, context: CloudBackupEnableContext) { - send!( - self.supervisor.start_operation(CloudBackupOperation::EnableNoDiscovery(context), None) - ); + send!(self.supervisor.start_enable_no_discovery_operation(context)); } pub(crate) fn disable_cloud_backup(&self) { - send!(self.supervisor.start_operation(CloudBackupOperation::Disable, None)); + send!(self.supervisor.start_disable_operation()); } pub(crate) fn keep_cloud_backup_enabled(&self) { @@ -1459,6 +994,18 @@ mod tests { ); } + #[test] + fn persistable_cloud_storage_issue_filters_other_for_persistence() { + assert_eq!( + RustCloudBackupManager::persistable_cloud_storage_issue(CloudStorageIssue::Offline), + Some(CloudStorageIssue::Offline) + ); + assert_eq!( + RustCloudBackupManager::persistable_cloud_storage_issue(CloudStorageIssue::Other), + None + ); + } + #[test] fn corrupted_persisted_state_projects_runtime_error() { assert_eq!( @@ -1471,8 +1018,8 @@ mod tests { #[test] fn pending_verification_completion_expires_future_created_at() { - let completion = PendingVerificationCompletion { - report: DeepVerificationReport { + let mut completion = PendingVerificationCompletion::new( + DeepVerificationReport { master_key_wrapper_repaired: false, local_master_key_repaired: false, credential_recovered: false, @@ -1481,10 +1028,10 @@ mod tests { wallets_unsupported: 0, detail: None, }, - namespace_id: "namespace".into(), - uploads: Vec::new(), - created_at: Some(11), - }; + "namespace".into(), + Vec::new(), + ); + completion.created_at = Some(11); assert!(completion.is_expired(10, 60)); } @@ -1789,7 +1336,7 @@ mod tests { let _guard = async_test_lock().lock().await; let manager = init_manager(); manager.reconcile_runtime_status(CloudBackupStatus::Disabled); - manager.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + manager.clear_enable_progress_report(); manager.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); manager.project_exclusive_operation_started(CloudBackupExclusiveOperationClaim::new( diff --git a/rust/src/manager/cloud_backup_manager/actors.rs b/rust/src/manager/cloud_backup_manager/actors.rs index eabffdfd7..c33826b13 100644 --- a/rust/src/manager/cloud_backup_manager/actors.rs +++ b/rust/src/manager/cloud_backup_manager/actors.rs @@ -7,15 +7,16 @@ pub(crate) mod cleanup; pub(crate) mod restore; pub(crate) mod supervisor; -mod sync_health; +mod sync_health_worker; mod uploads; pub(crate) mod write; pub(crate) use self::cleanup::{CleanupExpectedWalletRecord, CleanupSourceNamespace}; pub(crate) use self::restore::CloudBackupRestoreEvent; -pub(crate) use self::supervisor::{CloudBackupOperation, CloudBackupSupervisor}; +pub(crate) use self::supervisor::CloudBackupSupervisor; +pub(crate) use self::sync_health_worker::CloudBackupSyncHealthWorker; pub(crate) use self::write::{ CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWalletCountRefresh, CloudBackupWriteBlocker, CloudBackupWriteClient, CloudBackupWriteCompletion, - CloudBackupWriteResultReceiver, CloudBackupWriteSupervisor, + CloudBackupWriteSupervisor, }; diff --git a/rust/src/manager/cloud_backup_manager/actors/restore.rs b/rust/src/manager/cloud_backup_manager/actors/restore.rs index 1fe083561..6bf094ab6 100644 --- a/rust/src/manager/cloud_backup_manager/actors/restore.rs +++ b/rust/src/manager/cloud_backup_manager/actors/restore.rs @@ -3,7 +3,6 @@ use cove_cspp::master_key::MasterKey; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient}; use cove_device::keychain::Keychain; use cove_device::passkey::PasskeyAccess; -use cove_util::ResultExt as _; use futures::stream::{self, StreamExt as _}; use tracing::{info, warn}; use zeroize::Zeroizing; @@ -16,10 +15,10 @@ use crate::manager::cloud_backup_manager::wallets::{ }; use crate::manager::cloud_backup_manager::{ - BlockingCloudStep, CLOUD_BACKUP_IO_CONCURRENCY, CloudBackupEnableOutcome, CloudBackupError, - CloudBackupRestoreFlow, CloudBackupRestoreOutcome, CloudBackupRestoreReport, CloudBackupStatus, - CloudBackupStore, CloudStorageIssue, RustCloudBackupManager, blocking_cloud_error, - is_connectivity_related_issue, offline_error_for_step, + BlockingCloudStep, CLOUD_BACKUP_IO_CONCURRENCY, CloudBackupError, CloudBackupRestoreFlow, + CloudBackupRestoreOutcome, CloudBackupRestoreReport, CloudBackupStatus, CloudBackupStore, + CloudStorageIssue, RustCloudBackupManager, blocking_cloud_error, is_connectivity_related_issue, + offline_error_for_step, }; use crate::manager::cloud_backup_manager::keychain::CloudBackupKeychain; @@ -140,11 +139,8 @@ impl RestoreOperation { Ok(()) } - pub(crate) async fn apply_enable_outcome( - &self, - outcome: CloudBackupEnableOutcome, - ) -> Result<(), CloudBackupError> { - call!(self.supervisor.apply_restore_enable_outcome(self.operation_claim, outcome)) + pub(crate) async fn clear_enable_progress(&self) -> Result<(), CloudBackupError> { + call!(self.supervisor.clear_restore_enable_progress(self.operation_claim)) .await .map_err(|_| CloudBackupError::Cancelled)? } @@ -198,7 +194,7 @@ impl RestoreOperation { manager: &RustCloudBackupManager, ) -> Result { manager.ensure_cloud_connectivity(BlockingCloudStep::Restore)?; - self.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared).await?; + self.clear_enable_progress().await?; self.apply_outcome(CloudBackupRestoreOutcome::ProgressCleared).await?; self.apply_status(CloudBackupStatus::Restoring).await?; self.send_restore_progress(CloudBackupRestoreFlow::Finding).await?; @@ -264,7 +260,9 @@ impl RestoreOperation { }; let existing_identities = crate::wallet_identity::collect_existing_wallet_identities() - .map_err_prefix("collect wallet identities", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("collect wallet identities", source) + })?; let mut restore_session = WalletRestoreSession::new(existing_identities); let mut downloaded_wallets = Vec::new(); @@ -580,22 +578,22 @@ pub(crate) fn save_restore_keychain_entries( if let Err((context, error)) = metadata_save_result { if let Err(rollback) = cloud_keychain.clear_local_state() { - return Err(CloudBackupError::Internal(format!( - "{context}: {error}; rollback failed: {rollback}" - ))); + return Err(CloudBackupError::Internal( + format!("{context}: {error}; rollback failed: {rollback}").into(), + )); } - return Err(CloudBackupError::Internal(format!("{context}: {error}"))); + return Err(CloudBackupError::Internal(format!("{context}: {error}").into())); } if let Err(error) = cspp.save_master_key(&master_key) { if let Err(rollback) = cloud_keychain.clear_local_state() { - return Err(CloudBackupError::Internal(format!( - "save master key: {error}; rollback failed: {rollback}" - ))); + return Err(CloudBackupError::Internal( + format!("save master key: {error}; rollback failed: {rollback}").into(), + )); } - return Err(CloudBackupError::Internal(format!("save master key: {error}"))); + return Err(CloudBackupError::Internal(format!("save master key: {error}").into())); } Ok(()) diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor.rs index 155d8a06b..aa66cce0c 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor.rs @@ -12,17 +12,15 @@ use cove_cspp::{backup_data::MASTER_KEY_RECORD_ID, master_key_crypto}; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_device::keychain::Keychain; use cove_tokio::task::spawn_actor; -use cove_util::ResultExt as _; use tracing::{error, info, warn}; +use super::CloudBackupSyncHealthWorker; use super::cleanup::{CleanupSourceNamespace, CloudBackupCleanupJob, CloudBackupCleanupWorker}; use super::restore::{self, CloudBackupRestoreEvent, RestoreOperation, RestoredPasskeyMaterial}; -use super::sync_health::CloudBackupSyncHealthWorker; use super::uploads::CloudBackupUploadWorker; use super::write::{ CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWriteBlocker, - CloudBackupWriteClient, CloudBackupWriteCommandResult, CloudBackupWriteCompletion, - CloudBackupWriteResultReceiver, CloudBackupWriteSupervisor, + CloudBackupWriteClient, CloudBackupWriteCompletion, CloudBackupWriteSupervisor, }; use crate::database::Database; use crate::database::cloud_backup::PersistedCloudBackupState; @@ -46,21 +44,21 @@ use crate::manager::cloud_backup_manager::{ BlockingCloudStep, CloudBackupCloudOnlyFetchOutcome, CloudBackupCloudOnlyOperationWarning, CloudBackupCloudOnlyWalletOutcome, CloudBackupDetailOutcome, CloudBackupDetailResult, CloudBackupDisableOutcome, CloudBackupDisablePreparation, CloudBackupEnableContext, - CloudBackupEnableOutcome, CloudBackupEnablePasskeyPreparation, - CloudBackupEnablePasskeyRegistration, CloudBackupEnablePreparation, - CloudBackupEnableRecoveryCompletion, CloudBackupEnableRecoveryPreparation, CloudBackupError, + CloudBackupEnablePasskeyPreparation, CloudBackupEnablePasskeyRegistration, + CloudBackupEnablePreparation, CloudBackupEnableRecoveryCompletion, + CloudBackupEnableRecoveryPreparation, CloudBackupEnableState, CloudBackupError, CloudBackupKeepEnabledPreparation, CloudBackupNoDiscoveryEnablePreparation, CloudBackupOtherBackupsOutcome, CloudBackupPasskeyChoiceIntent, - CloudBackupPreparedCloudWalletDelete, CloudBackupReadyEnableUpload, CloudBackupRecoveryOutcome, + CloudBackupPreparedCloudWalletDelete, CloudBackupReadyEnableUpload, CloudBackupRegisteredEnablePasskey, CloudBackupRestoreOutcome, CloudBackupRestoreReport, CloudBackupReuploadedWallets, CloudBackupSavedPasskeyConfirmation, CloudBackupStatus, - CloudBackupSyncOutcome, CloudBackupUploadedEnableBackup, CloudBackupVerificationOutcome, - CloudBackupVerificationPresentation, CloudBackupVerificationSource, CloudBackupWalletItem, - DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, - EnablePasskeyRegistrationFlow, OtherBackupsOperation, PendingEnableSession, - PendingUploadVerificationState, PendingVerificationCompletion, PendingVerificationUpload, - RecoveryAction, RustCloudBackupManager, SavedPasskeyConfirmationMode, VerificationState, - WalletId, blocking_cloud_error, + CloudBackupUploadedEnableBackup, CloudBackupVerificationPresentation, + CloudBackupVerificationSource, CloudBackupWalletItem, DeepVerificationFailure, + DeepVerificationReport, DeepVerificationResult, EnablePasskeyRegistrationFlow, + OtherBackupsOperation, PendingEnableSession, PendingUploadVerificationState, + PendingVerificationCompletion, PendingVerificationUpload, RecoveryAction, RecoveryState, + RustCloudBackupManager, SavedPasskeyConfirmationMode, SyncState, VerificationState, WalletId, + blocking_cloud_error, }; use crate::manager::connectivity_manager::ConnectivityStatus; @@ -69,6 +67,8 @@ mod disable; mod enable; mod verification; +pub(crate) use verification::DeepVerificationContinuation; + mod tests { #![cfg(test)] @@ -77,23 +77,6 @@ mod tests { static NEXT_SUPERVISOR_OPERATION_ID: AtomicU64 = AtomicU64::new(0); -/// User or system operation requested of the Cloud Backup supervisor -#[derive(Debug, Clone)] -pub(crate) enum CloudBackupOperation { - Enable(CloudBackupEnableContext), - EnableForceNew(CloudBackupEnableContext), - EnableNoDiscovery(CloudBackupEnableContext), - Recovery(RecoveryAction), - RepairPasskey { no_discovery: bool }, - Sync, - FetchCloudOnly, - Disable, - RestoreCloudWallet, - DeleteCloudWallet, - RecoverOtherBackups, - DeleteOtherBackups, -} - /// Passkey proof cached only for the current supervisor session /// /// The cache lets detail entry reuse fresh authorization after enable or repair, @@ -265,48 +248,29 @@ impl CloudBackupSupervisor { Some(self.addr.upgrade()) } - async fn await_cloud_backup_write_for_operation( - receiver: CloudBackupWriteResultReceiver, - origin: CloudBackupExclusiveOperationClaim, - ) -> Result { - let result: CloudBackupWriteCommandResult = receiver - .await - .map_err_prefix("wait for cloud backup write supervisor", CloudBackupError::Internal)?; - let context = result.context(); - if context.origin() != Some(origin) { - return Err(CloudBackupError::Internal(format!( - "cloud backup write supervisor returned mismatched operation origin for command {:?}", - context.id() - ))); - } - - result.into_result() - } - async fn delete_prepared_cloud_wallet_for_operation( write: Addr, prepared: CloudBackupPreparedCloudWalletDelete, origin: CloudBackupExclusiveOperationClaim, ) -> Result<(), CloudBackupError> { - let receiver = call!(write.delete_active_wallet_backup_for_operation( - prepared.cloud, - prepared.namespace, - prepared.record_id.clone(), - origin - )) - .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; - - Self::await_cloud_backup_write_for_operation(receiver, origin).await.map_err(|error| { - let error = match error { - CloudBackupError::CloudStorage(source) => { - CloudBackupError::cloud_storage_context("delete wallet backup", source) - } - error => error, - }; + let writes = CloudBackupWriteClient::for_operation(write, origin); + writes + .delete_active_wallet_backup( + prepared.cloud, + prepared.namespace, + prepared.record_id.clone(), + ) + .await + .map_err(|error| { + let error = match error { + CloudBackupError::CloudStorage(source) => { + CloudBackupError::cloud_storage_context("delete wallet backup", source) + } + error => error, + }; - blocking_cloud_error(BlockingCloudStep::DeleteCloudWallet, error) - })?; + blocking_cloud_error(BlockingCloudStep::DeleteCloudWallet, error) + })?; info!("Deleted cloud wallet"); Ok(()) @@ -317,15 +281,9 @@ impl CloudBackupSupervisor { namespace: String, origin: CloudBackupExclusiveOperationClaim, ) -> Result<(), CloudBackupError> { - let receiver = call!(write.delete_namespace_for_operation( - CloudStorage::global_explicit_client(), - namespace, - origin - )) - .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; - - Self::await_cloud_backup_write_for_operation(receiver, origin).await + CloudBackupWriteClient::for_operation(write, origin) + .delete_namespace(CloudStorage::global_explicit_client(), namespace) + .await } async fn apply_cloud_backup_write_completion_for_operation( @@ -333,11 +291,7 @@ impl CloudBackupSupervisor { completion: CloudBackupWriteCompletion, origin: CloudBackupExclusiveOperationClaim, ) -> Result<(), CloudBackupError> { - let receiver = call!(write.apply_completion_for_operation(completion, origin)) - .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; - - Self::await_cloud_backup_write_for_operation(receiver, origin).await + CloudBackupWriteClient::for_operation(write, origin).apply_completion(completion).await } fn begin_exclusive_operation( @@ -449,10 +403,9 @@ impl CloudBackupSupervisor { Produces::ok(Ok(())) } - pub async fn apply_restore_enable_outcome( + pub async fn clear_restore_enable_progress( &mut self, claim: CloudBackupExclusiveOperationClaim, - outcome: CloudBackupEnableOutcome, ) -> ActorResult> { if !self.restore_operation_is_current(claim) { return Produces::ok(Err(CloudBackupError::Cancelled)); @@ -461,7 +414,7 @@ impl CloudBackupSupervisor { return Produces::ok(Err(CloudBackupError::Cancelled)); }; - manager.apply_enable_outcome(outcome); + manager.clear_enable_progress_report(); Produces::ok(Ok(())) } @@ -481,7 +434,7 @@ impl CloudBackupSupervisor { let result = Database::global() .cloud_backup_state .set(&state) - .map_err(|error| CloudBackupError::Internal(format!("{context}: {error}"))); + .map_err(|error| CloudBackupError::Internal(format!("{context}: {error}").into())); if result.is_ok() { manager.reconcile_runtime_status(RustCloudBackupManager::runtime_status_for(&state)); manager.refresh_persisted_flags(); @@ -505,44 +458,87 @@ impl CloudBackupSupervisor { Produces::ok(result) } - fn spawn_operation(&mut self, operation: CloudBackupOperation, record_id: Option) { - match operation { - CloudBackupOperation::Enable(context) => self.start_enable_operation(context), - CloudBackupOperation::EnableForceNew(context) => { - self.start_enable_force_new_operation(context); - } - CloudBackupOperation::EnableNoDiscovery(context) => { - self.start_enable_no_discovery_operation(context); - } - CloudBackupOperation::Recovery(action) => self.start_recovery_operation(action), - CloudBackupOperation::RepairPasskey { no_discovery } => { - self.start_repair_passkey_operation(no_discovery); - } - CloudBackupOperation::Sync => { - let Some(manager) = self.manager() else { return }; - self.start_sync_request(manager); - } - CloudBackupOperation::FetchCloudOnly => self.start_cloud_only_fetch_request(), - CloudBackupOperation::Disable => self.start_disable_operation(), - CloudBackupOperation::RestoreCloudWallet => { - let Some(record_id) = record_id else { return }; - self.start_restore_cloud_wallet_operation(record_id); - } - CloudBackupOperation::DeleteCloudWallet => { - let Some(record_id) = record_id else { return }; - self.start_delete_cloud_wallet_operation(record_id); - } - CloudBackupOperation::RecoverOtherBackups => { - self.start_recover_other_backups_operation() - } - CloudBackupOperation::DeleteOtherBackups => self.start_delete_other_backups_operation(), - } + pub async fn start_enable_operation( + &mut self, + context: CloudBackupEnableContext, + ) -> ActorResult<()> { + self.begin_enable_operation(context); + Produces::ok(()) + } + + pub async fn start_enable_force_new_operation( + &mut self, + context: CloudBackupEnableContext, + ) -> ActorResult<()> { + self.begin_enable_force_new_operation(context); + Produces::ok(()) + } + + pub async fn start_enable_no_discovery_operation( + &mut self, + context: CloudBackupEnableContext, + ) -> ActorResult<()> { + self.begin_enable_no_discovery_operation(context); + Produces::ok(()) + } + + pub async fn start_recovery_operation(&mut self, action: RecoveryAction) -> ActorResult<()> { + self.begin_recovery_operation(action); + Produces::ok(()) + } + + pub async fn start_repair_passkey_operation(&mut self, no_discovery: bool) -> ActorResult<()> { + self.begin_repair_passkey_operation(no_discovery); + Produces::ok(()) + } + + pub async fn start_sync_operation(&mut self) -> ActorResult<()> { + let Some(manager) = self.manager() else { return Produces::ok(()) }; + + self.start_sync_request(manager); + Produces::ok(()) + } + + pub async fn start_cloud_only_fetch_request(&mut self) -> ActorResult<()> { + self.begin_cloud_only_fetch_request(); + Produces::ok(()) + } + + pub async fn start_disable_operation(&mut self) -> ActorResult<()> { + self.begin_disable_operation(); + Produces::ok(()) + } + + pub async fn start_restore_cloud_wallet_operation( + &mut self, + record_id: String, + ) -> ActorResult<()> { + self.begin_restore_cloud_wallet_operation(record_id); + Produces::ok(()) + } + + pub async fn start_delete_cloud_wallet_operation( + &mut self, + record_id: String, + ) -> ActorResult<()> { + self.begin_delete_cloud_wallet_operation(record_id); + Produces::ok(()) + } + + pub async fn start_recover_other_backups_operation(&mut self) -> ActorResult<()> { + self.begin_recover_other_backups_operation(); + Produces::ok(()) + } + + pub async fn start_delete_other_backups_operation(&mut self) -> ActorResult<()> { + self.begin_delete_other_backups_operation(); + Produces::ok(()) } fn start_sync_request(&mut self, manager: Arc) { let request_id = self.next_request_id(); self.active_sync_request = Some(request_id); - manager.apply_sync_outcome(CloudBackupSyncOutcome::Started); + manager.apply_sync_state(SyncState::Syncing); self.addr.send_fut_with(move |addr| async move { let result = manager.do_sync_unsynced_wallets().await; @@ -572,7 +568,7 @@ impl CloudBackupSupervisor { }); } Err(error) => { - manager.apply_sync_outcome(CloudBackupSyncOutcome::Failed(error.to_string())); + manager.apply_sync_state(SyncState::Failed(error.to_string())); self.active_sync_request = None; } } @@ -597,20 +593,11 @@ impl CloudBackupSupervisor { apply_refresh_detail_result(&manager, &result); } - manager.apply_sync_outcome(CloudBackupSyncOutcome::Completed); + manager.apply_sync_state(SyncState::Idle); self.active_sync_request = None; Produces::ok(()) } - pub async fn start_operation( - &mut self, - operation: CloudBackupOperation, - record_id: Option, - ) -> ActorResult<()> { - self.spawn_operation(operation, record_id); - Produces::ok(()) - } - pub async fn start_refresh_detail(&mut self) -> ActorResult<()> { self.start_refresh_detail_with_context(DetailRefreshAttempt::Initial).await } @@ -952,7 +939,7 @@ impl CloudBackupSupervisor { self.active_operation = None; manager.project_exclusive_operation_finished(claim); - manager.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + manager.clear_enable_progress_report(); manager.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); manager.reconcile_runtime_status(RustCloudBackupManager::runtime_status_for( &RustCloudBackupManager::load_persisted_state(), diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/cloud_only.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/cloud_only.rs index f982ef1b1..51f63c812 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/cloud_only.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/cloud_only.rs @@ -1,7 +1,7 @@ use super::*; impl CloudBackupSupervisor { - pub(crate) fn start_cloud_only_fetch_request(&mut self) { + pub(crate) fn begin_cloud_only_fetch_request(&mut self) { let Some(manager) = self.manager() else { return }; let request_id = self.next_request_id(); self.active_cloud_only_fetch_request = Some(request_id); @@ -13,7 +13,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_restore_cloud_wallet_operation(&mut self, record_id: String) { + pub(crate) fn begin_restore_cloud_wallet_operation(&mut self, record_id: String) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = self @@ -31,7 +31,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_delete_cloud_wallet_operation(&mut self, record_id: String) { + pub(crate) fn begin_delete_cloud_wallet_operation(&mut self, record_id: String) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = self @@ -49,7 +49,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_recover_other_backups_operation(&mut self) { + pub(crate) fn begin_recover_other_backups_operation(&mut self) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = Self::begin_other_backups_operation( @@ -67,7 +67,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_delete_other_backups_operation(&mut self) { + pub(crate) fn begin_delete_other_backups_operation(&mut self) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = Self::begin_other_backups_operation( @@ -282,7 +282,7 @@ impl CloudBackupSupervisor { wallets_failed: report.wallets_failed, failed_wallet_errors: report.failed_wallet_errors, }); - manager.apply_sync_outcome(CloudBackupSyncOutcome::Started); + manager.apply_sync_state(SyncState::Syncing); self.addr.send_fut_with(move |addr| async move { let result = manager.do_sync_unsynced_wallets().await; send!(addr.complete_operation_sync(claim, result)); @@ -358,7 +358,7 @@ impl CloudBackupSupervisor { }); } Err(error) => { - manager.apply_sync_outcome(CloudBackupSyncOutcome::Failed(error.to_string())); + manager.apply_sync_state(SyncState::Failed(error.to_string())); self.active_operation = None; manager.project_exclusive_operation_finished(claim); } @@ -385,7 +385,7 @@ impl CloudBackupSupervisor { apply_refresh_detail_result(&manager, &result); } - manager.apply_sync_outcome(CloudBackupSyncOutcome::Completed); + manager.apply_sync_state(SyncState::Idle); self.active_operation = None; manager.project_exclusive_operation_finished(claim); Produces::ok(()) diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/disable.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/disable.rs index 6f5dc2c49..3a7e96a11 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/disable.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/disable.rs @@ -1,7 +1,7 @@ use super::*; impl CloudBackupSupervisor { - pub(crate) fn start_disable_operation(&mut self) { + pub(crate) fn begin_disable_operation(&mut self) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = @@ -128,13 +128,13 @@ impl CloudBackupSupervisor { self.runtime_passkey_authorization = None; call!(self.sync_health.clear_upload_runtime_state()) .await - .map_err(|error| CloudBackupError::Internal(error.to_string()))?; + .map_err(CloudBackupError::internal)?; call!(self.uploads.clear_upload_runtime_state()) .await - .map_err(|error| CloudBackupError::Internal(error.to_string()))?; + .map_err(CloudBackupError::internal)?; manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - manager.apply_sync_outcome(CloudBackupSyncOutcome::Completed); + manager.apply_sync_state(SyncState::Idle); Ok(()) } @@ -419,7 +419,7 @@ impl CloudBackupSupervisor { call!(self.uploads.resume_wallet_uploads_from_persisted_state()).await { error!("Failed to resume cloud backup uploads after Keep Enabled: {error}"); - manager.apply_sync_outcome(CloudBackupSyncOutcome::Failed(error.to_string())); + manager.apply_sync_state(SyncState::Failed(error.to_string())); } if let Err(error) = call!(self.uploads.ensure_pending_upload_verification_loop()).await @@ -427,7 +427,7 @@ impl CloudBackupSupervisor { error!( "Failed to resume pending cloud backup verification after Keep Enabled: {error}" ); - manager.apply_sync_outcome(CloudBackupSyncOutcome::Failed(error.to_string())); + manager.apply_sync_state(SyncState::Failed(error.to_string())); } manager.refresh_sync_health(); diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable.rs index 43bf0ecb8..17118214b 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable.rs @@ -89,9 +89,9 @@ impl CloudBackupSupervisor { } warn!("Enable failed: {error}"); - manager.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + manager.clear_enable_progress_report(); manager.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); - manager.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + manager.apply_enable_state(CloudBackupEnableState::Idle); manager .reconcile_runtime_status(RustCloudBackupManager::status_for_operation_error(&error)); self.active_operation = None; @@ -107,7 +107,7 @@ impl CloudBackupSupervisor { warn!("Reinitialize backup enable failed: {error}"); match error { CloudBackupError::UnsupportedPasskeyProvider => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); manager.reconcile_runtime_status( RustCloudBackupManager::status_for_operation_error( &CloudBackupError::UnsupportedPasskeyProvider, @@ -119,7 +119,7 @@ impl CloudBackupSupervisor { &RustCloudBackupManager::load_persisted_state(), ); manager.reconcile_runtime_status(runtime_status); - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::ReinitializeBackup, error: error.to_string(), }); @@ -136,12 +136,18 @@ impl CloudBackupSupervisor { claim: CloudBackupExclusiveOperationClaim, ) { if claim.operation() == CloudBackupExclusiveOperation::ReinitializeBackup { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); let runtime_status = RustCloudBackupManager::runtime_status_for( &RustCloudBackupManager::load_persisted_state(), ); if matches!(runtime_status, CloudBackupStatus::Enabled) { - self.start_reinitialize_verification(manager, claim, VerificationAttempt::Initial); + self.start_verification_with_context( + manager, + Some(claim), + DeepVerificationContinuation::ReinitializeBackup { + attempt: VerificationAttempt::Initial, + }, + ); return; } } diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/discard.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/discard.rs index 6c5c041bc..104955630 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/discard.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/discard.rs @@ -30,7 +30,7 @@ impl CloudBackupSupervisor { pub async fn discard_pending_enable_cloud_backup(&mut self) -> ActorResult<()> { let Some(pending) = self.pending_enable_session.take() else { if let Some(manager) = self.manager() { - manager.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + manager.apply_enable_state(CloudBackupEnableState::Idle); manager.reconcile_runtime_status(CloudBackupStatus::Disabled); } return Produces::ok(()); @@ -58,7 +58,7 @@ impl CloudBackupSupervisor { } if let Some(manager) = self.manager() { - manager.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + manager.apply_enable_state(CloudBackupEnableState::Idle); manager.reconcile_runtime_status(CloudBackupStatus::Disabled); } @@ -76,11 +76,15 @@ impl CloudBackupSupervisor { MASTER_KEY_RECORD_ID.to_string() )) .await - .map_err_prefix("start pending enable remote cleanup", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("start pending enable remote cleanup", source) + })?; receiver .await - .map_err_prefix("wait for pending enable remote cleanup", CloudBackupError::Internal)? + .map_err(|source| { + CloudBackupError::internal_context("wait for pending enable remote cleanup", source) + })? .into_result() .or_else(|error| match error { CloudBackupError::CloudStorage(CloudStorageError::NotFound(_)) => Ok(()), diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/passkey_confirmation.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/passkey_confirmation.rs index bc25021ff..134555600 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/passkey_confirmation.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/passkey_confirmation.rs @@ -51,7 +51,7 @@ impl CloudBackupSupervisor { confirmed.passkey.copy_for_retry(), confirmed.context, )); - manager.apply_enable_outcome(CloudBackupEnableOutcome::UploadingBackup); + manager.apply_enable_state(CloudBackupEnableState::UploadingBackup); self.schedule_enable_upload(manager, claim, confirmed); } CloudBackupSavedPasskeyConfirmation::Retry { pending, error } @@ -59,12 +59,11 @@ impl CloudBackupSupervisor { { warn!("Automatic saved passkey confirmation will retry: {error}"); self.pending_enable_session = Some(pending); - manager - .apply_enable_outcome(CloudBackupEnableOutcome::WaitingForPasskeyAvailability); + manager.apply_enable_state(CloudBackupEnableState::WaitingForPasskeyAvailability); if !self.schedule_enable_saved_passkey_wait_with_retry(claim, retry.after_retry()) { - manager.apply_enable_outcome( - CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( + manager.apply_enable_state( + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( SavedPasskeyConfirmationMode::Manual, ), ); @@ -75,8 +74,8 @@ impl CloudBackupSupervisor { CloudBackupSavedPasskeyConfirmation::Retry { pending, error } => { warn!("Confirm saved passkey will retry: {error}"); self.pending_enable_session = Some(pending); - manager.apply_enable_outcome( - CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( + manager.apply_enable_state( + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( SavedPasskeyConfirmationMode::Manual, ), ); @@ -104,7 +103,7 @@ impl CloudBackupSupervisor { return false; } - manager.apply_enable_outcome(CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( + manager.apply_enable_state(CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( SavedPasskeyConfirmationMode::Manual, )); self.finish_enable_operation(manager, claim); @@ -174,8 +173,8 @@ impl CloudBackupSupervisor { match retry { SavedPasskeyConfirmationRetry::Manual => { - manager.apply_enable_outcome( - CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( + manager.apply_enable_state( + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( SavedPasskeyConfirmationMode::Manual, ), ); @@ -217,7 +216,7 @@ impl CloudBackupSupervisor { return false; }; - manager.apply_enable_outcome(CloudBackupEnableOutcome::ConfirmingSavedPasskey); + manager.apply_enable_state(CloudBackupEnableState::ConfirmingSavedPasskey); addr.send_fut_with(move |addr| async move { let result = manager.confirm_saved_passkey_from_session(pending).await; send!(addr.complete_saved_passkey_confirmation(claim, retry, result)); diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/recovery.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/recovery.rs index 30ff447b7..a8fb92200 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/recovery.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/recovery.rs @@ -97,7 +97,9 @@ impl CloudBackupSupervisor { CloudBackupKeychain::new(Keychain::global().clone()) .save_passkey_and_namespace(&credential_id, prf_salt, &namespace_id) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("save cspp credentials", source) + })?; self.runtime_passkey_authorization = Some(RuntimePasskeyAuthorization { namespace_id: namespace_id.clone(), @@ -158,7 +160,7 @@ impl CloudBackupSupervisor { sources: cleanup_sources, })) .await - .map_err_str(CloudBackupError::Internal)?; + .map_err(CloudBackupError::internal)?; let should_finalize_now = pending_uploads.is_empty(); let report = DeepVerificationReport { @@ -174,7 +176,7 @@ impl CloudBackupSupervisor { PendingVerificationCompletion::new(report, namespace_id, pending_uploads), context.verification_source, ); - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); + manager.apply_verification_state(VerificationState::Idle); self.pending_enable_session = None; manager.clear_enable_progress(CloudBackupStatus::Enabled); manager.refresh_persisted_flags(); diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/start.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/start.rs index 501b7271f..de8aded7a 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/start.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/start.rs @@ -1,7 +1,7 @@ use super::*; impl CloudBackupSupervisor { - pub(crate) fn start_enable_operation(&mut self, context: CloudBackupEnableContext) { + pub(crate) fn begin_enable_operation(&mut self, context: CloudBackupEnableContext) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = @@ -38,7 +38,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_enable_force_new_operation(&mut self, context: CloudBackupEnableContext) { + pub(crate) fn begin_enable_force_new_operation(&mut self, context: CloudBackupEnableContext) { let Some(manager) = self.manager() else { return }; let Some(claim) = self.begin_exclusive_operation(&manager, CloudBackupExclusiveOperation::EnableForceNew) @@ -64,7 +64,7 @@ impl CloudBackupSupervisor { return; } - manager.apply_enable_outcome(CloudBackupEnableOutcome::CreatingPasskey); + manager.apply_enable_state(CloudBackupEnableState::CreatingPasskey); self.schedule_enable_passkey_registration( manager, claim, @@ -73,7 +73,7 @@ impl CloudBackupSupervisor { ); } - pub(crate) fn start_enable_no_discovery_operation( + pub(crate) fn begin_enable_no_discovery_operation( &mut self, context: CloudBackupEnableContext, ) { @@ -121,9 +121,7 @@ impl CloudBackupSupervisor { return; }; - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Started( - RecoveryAction::ReinitializeBackup, - )); + manager.apply_recovery_state(RecoveryState::Recovering(RecoveryAction::ReinitializeBackup)); match self.start_ready_enable_upload_if_present( manager.clone(), diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/upload_finalization.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/upload_finalization.rs index 741ae4899..7b2d7dd33 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/upload_finalization.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/enable/upload_finalization.rs @@ -16,7 +16,7 @@ impl CloudBackupSupervisor { match result { Ok(CloudBackupEnablePreparation::CreateNew { context }) => { - manager.apply_enable_outcome(CloudBackupEnableOutcome::CreatingPasskey); + manager.apply_enable_state(CloudBackupEnableState::CreatingPasskey); self.schedule_create_new_enable_passkey(manager, claim, context); } Ok(CloudBackupEnablePreparation::ExistingBackupFound { context, passkey_hint }) => { @@ -84,7 +84,7 @@ impl CloudBackupSupervisor { ready.passkey.copy_for_retry(), ready.context, )); - manager.apply_enable_outcome(CloudBackupEnableOutcome::UploadingBackup); + manager.apply_enable_state(CloudBackupEnableState::UploadingBackup); self.schedule_enable_upload(manager, claim, ready); } Ok(CloudBackupEnablePasskeyPreparation::Registered(registered)) => { @@ -121,7 +121,7 @@ impl CloudBackupSupervisor { match result { Ok(CloudBackupNoDiscoveryEnablePreparation::RegisterPasskey { context }) => { - manager.apply_enable_outcome(CloudBackupEnableOutcome::CreatingPasskey); + manager.apply_enable_state(CloudBackupEnableState::CreatingPasskey); self.schedule_enable_passkey_registration( manager, claim, @@ -232,13 +232,11 @@ impl CloudBackupSupervisor { registered.passkey, registered.context, )); - manager.apply_enable_outcome(CloudBackupEnableOutcome::WaitingForPasskeyAvailability); + manager.apply_enable_state(CloudBackupEnableState::WaitingForPasskeyAvailability); if !self.schedule_enable_saved_passkey_wait(claim, saved_passkey_confirmation) { - manager.apply_enable_outcome( - CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ), - ); + manager.apply_enable_state(CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + )); self.active_operation = None; manager.project_exclusive_operation_finished(claim); } @@ -254,7 +252,7 @@ impl CloudBackupSupervisor { return Ok(false); }; - manager.apply_enable_outcome(CloudBackupEnableOutcome::UploadingBackup); + manager.apply_enable_state(CloudBackupEnableState::UploadingBackup); self.schedule_enable_upload(manager, claim, ready); Ok(true) } @@ -338,7 +336,9 @@ impl CloudBackupSupervisor { upload.passkey.prf_salt, &upload.namespace_id, ) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("save cspp credentials", source) + })?; let completion = CloudBackupWriteCompletion::mark_uploaded_pending_confirmation( upload.namespace_id.clone(), @@ -419,7 +419,7 @@ impl CloudBackupSupervisor { let decrypted_master = master_key_crypto::decrypt_master_key(&encrypted_master, &passkey.prf_key) - .map_err_str(CloudBackupError::Crypto); + .map_err(CloudBackupError::crypto); let decrypted_master = match decrypted_master { Ok(decrypted_master) => decrypted_master, Err(error) => { @@ -458,7 +458,7 @@ impl CloudBackupSupervisor { PendingVerificationCompletion::new(report, namespace_id, pending_uploads), context.verification_source, ); - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); + manager.apply_verification_state(VerificationState::Idle); self.pending_enable_session = None; manager.clear_enable_progress(CloudBackupStatus::Enabled); manager.refresh_persisted_flags(); diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs index cadecc696..01004c54a 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs @@ -597,10 +597,12 @@ async fn supervisor_ignores_stale_recreate_manifest_verification_completion() { ); supervisor - .complete_recreate_manifest_verification( - stale, + .complete_verification( + Some(stale), CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled), - VerificationAttempt::Initial, + DeepVerificationContinuation::RecreateManifest { + attempt: VerificationAttempt::Initial, + }, ) .await .unwrap(); @@ -609,10 +611,12 @@ async fn supervisor_ignores_stale_recreate_manifest_verification_completion() { assert_eq!(manager.projected_exclusive_operation(), Some(current)); supervisor - .complete_recreate_manifest_verification( - current, + .complete_verification( + Some(current), CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled), - VerificationAttempt::Initial, + DeepVerificationContinuation::RecreateManifest { + attempt: VerificationAttempt::Initial, + }, ) .await .unwrap(); @@ -913,10 +917,12 @@ async fn supervisor_ignores_stale_reinitialize_verification_completion() { ); supervisor - .complete_reinitialize_verification( - stale, + .complete_verification( + Some(stale), CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled), - VerificationAttempt::Initial, + DeepVerificationContinuation::ReinitializeBackup { + attempt: VerificationAttempt::Initial, + }, ) .await .unwrap(); @@ -925,10 +931,12 @@ async fn supervisor_ignores_stale_reinitialize_verification_completion() { assert_eq!(manager.projected_exclusive_operation(), Some(current)); supervisor - .complete_reinitialize_verification( - current, + .complete_verification( + Some(current), CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled), - VerificationAttempt::Initial, + DeepVerificationContinuation::ReinitializeBackup { + attempt: VerificationAttempt::Initial, + }, ) .await .unwrap(); diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/verification.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/verification.rs index b12c7c593..a3561eca5 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/verification.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/verification.rs @@ -8,21 +8,58 @@ pub(crate) enum DeepVerificationContinuation { ReinitializeBackup { attempt: VerificationAttempt }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeepVerificationTerminalPolicy { + ClearActiveOperationBeforeConnectivityRetry, + ClearActiveOperationAfterTerminalHandling, +} + impl DeepVerificationContinuation { + fn attempt(self) -> VerificationAttempt { + match self { + Self::Manual { attempt, .. } + | Self::RecreateManifest { attempt } + | Self::ReinitializeBackup { attempt } => attempt, + } + } + fn force_discoverable(self) -> bool { match self { Self::Manual { force_discoverable, .. } => force_discoverable, Self::RecreateManifest { .. } | Self::ReinitializeBackup { .. } => false, } } + + fn with_attempt(self, attempt: VerificationAttempt) -> Self { + match self { + Self::Manual { force_discoverable, .. } => Self::Manual { force_discoverable, attempt }, + Self::RecreateManifest { .. } => Self::RecreateManifest { attempt }, + Self::ReinitializeBackup { .. } => Self::ReinitializeBackup { attempt }, + } + } + + fn connectivity_retry(self) -> Self { + self.with_attempt(VerificationAttempt::AutomaticConnectivityRetry) + } + + fn terminal_policy(self) -> DeepVerificationTerminalPolicy { + match self { + Self::Manual { .. } => { + DeepVerificationTerminalPolicy::ClearActiveOperationBeforeConnectivityRetry + } + Self::RecreateManifest { .. } | Self::ReinitializeBackup { .. } => { + DeepVerificationTerminalPolicy::ClearActiveOperationAfterTerminalHandling + } + } + } } impl CloudBackupSupervisor { - pub(crate) fn start_recovery_operation(&mut self, action: RecoveryAction) { + pub(crate) fn begin_recovery_operation(&mut self, action: RecoveryAction) { match action { RecoveryAction::RecreateManifest => self.start_recreate_manifest_recovery(), RecoveryAction::ReinitializeBackup => self.start_reinitialize_backup_operation(), - RecoveryAction::RepairPasskey => self.start_repair_passkey_operation(false), + RecoveryAction::RepairPasskey => self.begin_repair_passkey_operation(false), } } @@ -42,7 +79,7 @@ impl CloudBackupSupervisor { }); } - pub(crate) fn start_repair_passkey_operation(&mut self, no_discovery: bool) { + pub(crate) fn begin_repair_passkey_operation(&mut self, no_discovery: bool) { let Some(manager) = self.manager() else { return }; let Some(addr) = self.addr() else { return }; let Some(claim) = @@ -51,9 +88,7 @@ impl CloudBackupSupervisor { return; }; - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Started( - RecoveryAction::RepairPasskey, - )); + manager.apply_recovery_state(RecoveryState::Recovering(RecoveryAction::RepairPasskey)); addr.send_fut_with(move |addr| async move { let result = if no_discovery { manager.prepare_passkey_wrapper_repair_no_discovery().await @@ -65,64 +100,78 @@ impl CloudBackupSupervisor { } pub async fn start_verification(&mut self, force_discoverable: bool) -> ActorResult<()> { - self.start_verification_with_context(force_discoverable, VerificationAttempt::Initial).await + let Some(manager) = self.manager() else { return Produces::ok(()) }; + + self.start_verification_with_context( + manager, + None, + DeepVerificationContinuation::Manual { + force_discoverable, + attempt: VerificationAttempt::Initial, + }, + ); + Produces::ok(()) } - async fn start_verification_with_context( + pub(crate) fn start_verification_with_context( &mut self, - force_discoverable: bool, - attempt: VerificationAttempt, - ) -> ActorResult<()> { - let Some(manager) = self.manager() else { return Produces::ok(()) }; - + manager: Arc, + claim: Option, + continuation: DeepVerificationContinuation, + ) { self.pending_verification_completion = None; if matches!( manager.state.read().verification_presentation(), CloudBackupVerificationPresentation::ManualVerifying { .. } ) { - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Started); + manager.apply_verification_state(VerificationState::Verifying); } else { manager.apply_verification_effect(CloudBackupVerificationCoordinator::begin_manual( CloudBackupVerificationSource::Settings, )); } - self.schedule_verification(manager, force_discoverable, attempt); - - Produces::ok(()) + self.schedule_verification(manager, claim, continuation); } fn schedule_verification( &self, manager: Arc, - force_discoverable: bool, - attempt: VerificationAttempt, + claim: Option, + continuation: DeepVerificationContinuation, ) { + let force_discoverable = continuation.force_discoverable(); self.addr.send_fut_with(move |addr| async move { let result = manager.prepare_deep_verify_cloud_backup(force_discoverable).await; - send!(addr.complete_verification(result, force_discoverable, attempt)); + send!(addr.complete_verification(claim, result, continuation)); }); } pub async fn complete_verification( &mut self, + claim: Option, result: CloudBackupDeepVerificationStep, - force_discoverable: bool, - attempt: VerificationAttempt, + continuation: DeepVerificationContinuation, ) -> ActorResult<()> { - let Some(manager) = self.manager() else { return Produces::ok(()) }; + if let Some(claim) = claim + && self.active_operation != Some(claim) + { + return Produces::ok(()); + } + let Some(manager) = self.manager() else { + if claim.is_some() { + self.active_operation = None; + } + return Produces::ok(()); + }; + let result = match result { CloudBackupDeepVerificationStep::Complete(result) => result, CloudBackupDeepVerificationStep::PreparedWrapperRepair(prepared) => { - let Some(claim) = self.begin_exclusive_operation( + let Some(claim) = self.claim_deep_verification_repair( &manager, - CloudBackupExclusiveOperation::VerificationRepair, + claim, + "cloud backup verification repair is waiting for another operation", ) else { - let result = DeepVerificationResult::Failed(DeepVerificationFailure::retry( - "cloud backup verification repair is waiting for another operation", - None, - None, - )); - manager.handle_deep_verification_result(result); return Produces::ok(()); }; @@ -130,47 +179,51 @@ impl CloudBackupSupervisor { manager, claim, *prepared, - DeepVerificationContinuation::Manual { force_discoverable, attempt }, + continuation, ); return Produces::ok(()); } CloudBackupDeepVerificationStep::PreparedAutoSync(prepared) => { - let Some(claim) = self.begin_exclusive_operation( + let Some(claim) = self.claim_deep_verification_repair( &manager, - CloudBackupExclusiveOperation::VerificationRepair, + claim, + "cloud backup verification auto-sync is waiting for another operation", ) else { - let result = DeepVerificationResult::Failed(DeepVerificationFailure::retry( - "cloud backup verification auto-sync is waiting for another operation", - None, - None, - )); - manager.handle_deep_verification_result(result); return Produces::ok(()); }; - self.start_deep_verification_auto_sync( - manager, - claim, - *prepared, - DeepVerificationContinuation::Manual { force_discoverable, attempt }, - ); + self.start_deep_verification_auto_sync(manager, claim, *prepared, continuation); return Produces::ok(()); } }; - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_verification( - manager, - force_discoverable, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return Produces::ok(()); + self.finish_deep_verification_continuation(manager, claim, continuation, result); + Produces::ok(()) + } + + fn claim_deep_verification_repair( + &mut self, + manager: &RustCloudBackupManager, + claim: Option, + waiting_message: &'static str, + ) -> Option { + if let Some(claim) = claim { + return Some(claim); } - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - Produces::ok(()) + let Some(claim) = self + .begin_exclusive_operation(manager, CloudBackupExclusiveOperation::VerificationRepair) + else { + let result = DeepVerificationResult::Failed(DeepVerificationFailure::retry( + waiting_message, + None, + None, + )); + manager.handle_deep_verification_result(result); + return None; + }; + + Some(claim) } fn start_deep_verification_wrapper_repair( @@ -222,7 +275,9 @@ impl CloudBackupSupervisor { Ok((resume, authorization)) => { if let Err(error) = CloudBackupKeychain::new(Keychain::global().clone()) .save_passkey(&authorization.credential_id, authorization.prf_salt) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal) + .map_err(|source| { + CloudBackupError::internal_context("save cspp credentials", source) + }) { self.finish_deep_verification_continuation_with_error( manager, @@ -277,7 +332,12 @@ impl CloudBackupSupervisor { match result { CloudBackupDeepVerificationStep::Complete(result) => { - self.finish_deep_verification_continuation(manager, claim, continuation, result); + self.finish_deep_verification_continuation( + manager, + Some(claim), + continuation, + result, + ); } CloudBackupDeepVerificationStep::PreparedAutoSync(prepared) => { self.start_deep_verification_auto_sync(manager, claim, *prepared, continuation); @@ -360,7 +420,12 @@ impl CloudBackupSupervisor { }); } Err(result) => { - self.finish_deep_verification_continuation(manager, claim, continuation, result); + self.finish_deep_verification_continuation( + manager, + Some(claim), + continuation, + result, + ); } } @@ -400,7 +465,12 @@ impl CloudBackupSupervisor { }); } Err(result) => { - self.finish_deep_verification_continuation(manager, claim, continuation, result); + self.finish_deep_verification_continuation( + manager, + Some(claim), + continuation, + result, + ); } } @@ -425,7 +495,7 @@ impl CloudBackupSupervisor { if let Some(pending_completion) = pending_completion { manager.replace_pending_verification_completion(pending_completion); } - self.finish_deep_verification_continuation(manager, claim, continuation, result); + self.finish_deep_verification_continuation(manager, Some(claim), continuation, result); Produces::ok(()) } @@ -440,69 +510,49 @@ impl CloudBackupSupervisor { continuation.force_discoverable(), error, ); - self.finish_deep_verification_continuation(manager, claim, continuation, result); + self.finish_deep_verification_continuation(manager, Some(claim), continuation, result); } fn finish_deep_verification_continuation( &mut self, manager: Arc, - claim: CloudBackupExclusiveOperationClaim, + mut claim: Option, continuation: DeepVerificationContinuation, result: DeepVerificationResult, ) { - match continuation { - DeepVerificationContinuation::Manual { force_discoverable, attempt } => { - self.active_operation = None; - manager.project_exclusive_operation_finished(claim); - - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_verification( - manager, - force_discoverable, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return; - } + if continuation.terminal_policy() + == DeepVerificationTerminalPolicy::ClearActiveOperationBeforeConnectivityRetry + && let Some(claim) = claim.take() + { + self.finish_deep_verification_operation(&manager, claim); + } - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - } - DeepVerificationContinuation::RecreateManifest { attempt } => { - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_recreate_manifest_verification( - manager, - claim, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return; - } + if verification_needs_connectivity_retry(&manager, continuation.attempt(), &result) { + manager.persist_verification_result(&result); + self.schedule_verification(manager, claim, continuation.connectivity_retry()); + return; + } - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - self.active_operation = None; - manager.project_exclusive_operation_finished(claim); - } - DeepVerificationContinuation::ReinitializeBackup { attempt } => { - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_reinitialize_verification( - manager, - claim, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return; - } + manager.persist_verification_result(&result); + manager.handle_deep_verification_result(result); - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - self.active_operation = None; - manager.project_exclusive_operation_finished(claim); - } + if continuation.terminal_policy() + == DeepVerificationTerminalPolicy::ClearActiveOperationAfterTerminalHandling + && let Some(claim) = claim + { + self.finish_deep_verification_operation(&manager, claim); } } + fn finish_deep_verification_operation( + &mut self, + manager: &RustCloudBackupManager, + claim: CloudBackupExclusiveOperationClaim, + ) { + self.active_operation = None; + manager.project_exclusive_operation_finished(claim); + } + pub async fn complete_recreate_manifest_recovery( &mut self, claim: CloudBackupExclusiveOperationClaim, @@ -532,7 +582,7 @@ impl CloudBackupSupervisor { }); } Err(CloudBackupError::UnsupportedPasskeyProvider) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); manager.reconcile_runtime_status( RustCloudBackupManager::status_for_operation_error( &CloudBackupError::UnsupportedPasskeyProvider, @@ -542,7 +592,7 @@ impl CloudBackupSupervisor { manager.project_exclusive_operation_finished(claim); } Err(error) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RecreateManifest, error: error.to_string(), }); @@ -569,15 +619,17 @@ impl CloudBackupSupervisor { match result { Ok(()) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); - self.start_recreate_manifest_verification( + manager.apply_recovery_state(RecoveryState::Idle); + self.start_verification_with_context( manager, - claim, - VerificationAttempt::Initial, + Some(claim), + DeepVerificationContinuation::RecreateManifest { + attempt: VerificationAttempt::Initial, + }, ); } Err(error) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RecreateManifest, error: error.to_string(), }); @@ -589,174 +641,6 @@ impl CloudBackupSupervisor { Produces::ok(()) } - pub(crate) fn start_reinitialize_verification( - &mut self, - manager: Arc, - claim: CloudBackupExclusiveOperationClaim, - attempt: VerificationAttempt, - ) { - self.pending_verification_completion = None; - if matches!( - manager.state.read().verification_presentation(), - CloudBackupVerificationPresentation::ManualVerifying { .. } - ) { - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Started); - } else { - manager.apply_verification_effect(CloudBackupVerificationCoordinator::begin_manual( - CloudBackupVerificationSource::Settings, - )); - } - self.schedule_reinitialize_verification(manager, claim, attempt); - } - - fn schedule_reinitialize_verification( - &self, - manager: Arc, - claim: CloudBackupExclusiveOperationClaim, - attempt: VerificationAttempt, - ) { - self.addr.send_fut_with(move |addr| async move { - let result = manager.prepare_deep_verify_cloud_backup(false).await; - send!(addr.complete_reinitialize_verification(claim, result, attempt)); - }); - } - - pub async fn complete_reinitialize_verification( - &mut self, - claim: CloudBackupExclusiveOperationClaim, - result: CloudBackupDeepVerificationStep, - attempt: VerificationAttempt, - ) -> ActorResult<()> { - if self.active_operation != Some(claim) { - return Produces::ok(()); - } - let Some(manager) = self.manager() else { - self.active_operation = None; - return Produces::ok(()); - }; - let result = match result { - CloudBackupDeepVerificationStep::Complete(result) => result, - CloudBackupDeepVerificationStep::PreparedWrapperRepair(prepared) => { - self.start_deep_verification_wrapper_repair( - manager, - claim, - *prepared, - DeepVerificationContinuation::ReinitializeBackup { attempt }, - ); - return Produces::ok(()); - } - CloudBackupDeepVerificationStep::PreparedAutoSync(prepared) => { - self.start_deep_verification_auto_sync( - manager, - claim, - *prepared, - DeepVerificationContinuation::ReinitializeBackup { attempt }, - ); - return Produces::ok(()); - } - }; - - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_reinitialize_verification( - manager, - claim, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return Produces::ok(()); - } - - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - self.active_operation = None; - manager.project_exclusive_operation_finished(claim); - Produces::ok(()) - } - - fn start_recreate_manifest_verification( - &mut self, - manager: Arc, - claim: CloudBackupExclusiveOperationClaim, - attempt: VerificationAttempt, - ) { - self.pending_verification_completion = None; - if matches!( - manager.state.read().verification_presentation(), - CloudBackupVerificationPresentation::ManualVerifying { .. } - ) { - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Started); - } else { - manager.apply_verification_effect(CloudBackupVerificationCoordinator::begin_manual( - CloudBackupVerificationSource::Settings, - )); - } - self.schedule_recreate_manifest_verification(manager, claim, attempt); - } - - fn schedule_recreate_manifest_verification( - &self, - manager: Arc, - claim: CloudBackupExclusiveOperationClaim, - attempt: VerificationAttempt, - ) { - self.addr.send_fut_with(move |addr| async move { - let result = manager.prepare_deep_verify_cloud_backup(false).await; - send!(addr.complete_recreate_manifest_verification(claim, result, attempt)); - }); - } - - pub async fn complete_recreate_manifest_verification( - &mut self, - claim: CloudBackupExclusiveOperationClaim, - result: CloudBackupDeepVerificationStep, - attempt: VerificationAttempt, - ) -> ActorResult<()> { - if self.active_operation != Some(claim) { - return Produces::ok(()); - } - let Some(manager) = self.manager() else { - self.active_operation = None; - return Produces::ok(()); - }; - let result = match result { - CloudBackupDeepVerificationStep::Complete(result) => result, - CloudBackupDeepVerificationStep::PreparedWrapperRepair(prepared) => { - self.start_deep_verification_wrapper_repair( - manager, - claim, - *prepared, - DeepVerificationContinuation::RecreateManifest { attempt }, - ); - return Produces::ok(()); - } - CloudBackupDeepVerificationStep::PreparedAutoSync(prepared) => { - self.start_deep_verification_auto_sync( - manager, - claim, - *prepared, - DeepVerificationContinuation::RecreateManifest { attempt }, - ); - return Produces::ok(()); - } - }; - - if verification_needs_connectivity_retry(&manager, attempt, &result) { - manager.persist_verification_result(&result); - self.schedule_recreate_manifest_verification( - manager, - claim, - VerificationAttempt::AutomaticConnectivityRetry, - ); - return Produces::ok(()); - } - - manager.persist_verification_result(&result); - manager.handle_deep_verification_result(result); - self.active_operation = None; - manager.project_exclusive_operation_finished(claim); - Produces::ok(()) - } - pub async fn complete_repair_passkey_wrapper( &mut self, claim: CloudBackupExclusiveOperationClaim, @@ -789,14 +673,14 @@ impl CloudBackupSupervisor { }); } Err(CloudBackupError::PasskeyDiscoveryCancelled) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); manager .present_passkey_choice_prompt(CloudBackupPasskeyChoiceIntent::RepairPasskey); self.active_operation = None; manager.project_exclusive_operation_finished(claim); } Err(CloudBackupError::UnsupportedPasskeyProvider) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); manager.reconcile_runtime_status( RustCloudBackupManager::status_for_operation_error( &CloudBackupError::UnsupportedPasskeyProvider, @@ -806,7 +690,7 @@ impl CloudBackupSupervisor { manager.project_exclusive_operation_finished(claim); } Err(error) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RepairPasskey, error: error.to_string(), }); @@ -838,9 +722,11 @@ impl CloudBackupSupervisor { Ok((uploaded, authorization)) => { if let Err(error) = CloudBackupKeychain::new(Keychain::global().clone()) .save_passkey(&authorization.credential_id, authorization.prf_salt) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal) + .map_err(|source| { + CloudBackupError::internal_context("save cspp credentials", source) + }) { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RepairPasskey, error: error.to_string(), }); @@ -857,7 +743,7 @@ impl CloudBackupSupervisor { }); } Err(error) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RepairPasskey, error: error.to_string(), }); @@ -892,7 +778,7 @@ impl CloudBackupSupervisor { }); } Err(error) => { - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Failed { + manager.apply_recovery_state(RecoveryState::Failed { action: RecoveryAction::RepairPasskey, error: error.to_string(), }); @@ -928,8 +814,8 @@ impl CloudBackupSupervisor { } manager.refresh_sync_health(); - manager.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); + manager.apply_recovery_state(RecoveryState::Idle); + manager.apply_verification_state(VerificationState::Idle); self.active_operation = None; manager.project_exclusive_operation_finished(claim); Produces::ok(()) diff --git a/rust/src/manager/cloud_backup_manager/actors/sync_health.rs b/rust/src/manager/cloud_backup_manager/actors/sync_health_worker.rs similarity index 100% rename from rust/src/manager/cloud_backup_manager/actors/sync_health.rs rename to rust/src/manager/cloud_backup_manager/actors/sync_health_worker.rs diff --git a/rust/src/manager/cloud_backup_manager/actors/uploads.rs b/rust/src/manager/cloud_backup_manager/actors/uploads.rs index 0210f4ff0..00722aaa4 100644 --- a/rust/src/manager/cloud_backup_manager/actors/uploads.rs +++ b/rust/src/manager/cloud_backup_manager/actors/uploads.rs @@ -8,14 +8,13 @@ use tokio::sync::Notify; use tracing::{error, info, warn}; use crate::database::cloud_backup::{ - CloudBackupRecordKey, CloudBlobFailedState, CloudBlobFailureIssue, PersistedCloudBlobState, + CloudBackupRecordKey, CloudBlobFailedState, CloudStorageIssue, PersistedCloudBlobState, }; use crate::manager::cloud_backup_manager::pending::{ - MAX_PENDING_UPLOAD_VERIFICATION_DELAY, PendingUploadVerificationStatus, - build_pending_upload_backoff, + MAX_PENDING_UPLOAD_VERIFICATION_DELAY, build_pending_upload_backoff, }; use crate::manager::cloud_backup_manager::{ - CloudBackupError, CloudBackupSyncOutcome, RustCloudBackupManager, WalletId, + CloudBackupError, PendingUploadVerificationState, RustCloudBackupManager, SyncState, WalletId, live_upload_retry_delay_for_attempt, }; @@ -79,12 +78,12 @@ impl CloudBackupUploadWorker { } match manager.verify_pending_uploads_once().await { - PendingUploadVerificationStatus::Idle => break, - PendingUploadVerificationStatus::BlockedOnAuthorization => { + PendingUploadVerificationState::Idle => break, + PendingUploadVerificationState::BlockedOnAuthorization => { blocked_on_authorization = true; break; } - PendingUploadVerificationStatus::Pending => {} + PendingUploadVerificationState::Confirming => {} } let delay = backoff.next_delay(); @@ -296,9 +295,9 @@ impl CloudBackupUploadWorker { let message = format!("failed to load cloud blob sync states on startup: {error}"); error!("{message}"); if let Some(manager) = self.manager() { - manager.apply_sync_outcome(CloudBackupSyncOutcome::Failed(message.clone())); + manager.apply_sync_state(SyncState::Failed(message.clone())); } - return Err(CloudBackupError::Internal(message).into()); + return Err(CloudBackupError::Internal(message.into()).into()); } }; @@ -404,7 +403,7 @@ impl CloudBackupUploadWorker { } fn is_authorization_failed_blob(failed_state: &CloudBlobFailedState) -> bool { - failed_state.issue == Some(CloudBlobFailureIssue::AuthorizationRequired) + failed_state.issue == Some(CloudStorageIssue::AuthorizationRequired) } fn should_retry_failed_blob(failed_state: &CloudBlobFailedState) -> bool { diff --git a/rust/src/manager/cloud_backup_manager/actors/write.rs b/rust/src/manager/cloud_backup_manager/actors/write.rs index a3c5b175e..d1357b0b8 100644 --- a/rust/src/manager/cloud_backup_manager/actors/write.rs +++ b/rust/src/manager/cloud_backup_manager/actors/write.rs @@ -10,10 +10,7 @@ mod types; mod worker; pub(crate) use self::client::CloudBackupWriteClient; -pub(crate) use self::supervisor::{ - CloudBackupWriteBlocker, CloudBackupWriteCommandResult, CloudBackupWriteResultReceiver, - CloudBackupWriteSupervisor, -}; +pub(crate) use self::supervisor::{CloudBackupWriteBlocker, CloudBackupWriteSupervisor}; pub(crate) use self::types::{ CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWalletCountRefresh, CloudBackupWriteCompletion, diff --git a/rust/src/manager/cloud_backup_manager/actors/write/client.rs b/rust/src/manager/cloud_backup_manager/actors/write/client.rs index 26d78c58d..a8e80d6fa 100644 --- a/rust/src/manager/cloud_backup_manager/actors/write/client.rs +++ b/rust/src/manager/cloud_backup_manager/actors/write/client.rs @@ -1,13 +1,13 @@ use act_zero::{Addr, call}; use cove_device::cloud_storage::CloudStorageClient; -use cove_util::ResultExt as _; use crate::manager::cloud_backup_manager::CloudBackupError; use crate::manager::cloud_backup_manager::model::CloudBackupExclusiveOperationClaim; use super::supervisor::{CloudBackupWriteResultReceiver, CloudBackupWriteSupervisor}; use super::types::{ - CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWriteCompletion, + CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWalletCountRefresh, + CloudBackupWriteCompletion, }; /// Operation-aware handle for submitting writes to the write supervisor @@ -37,16 +37,17 @@ impl CloudBackupWriteClient { &self, receiver: CloudBackupWriteResultReceiver, ) -> Result { - let result = receiver - .await - .map_err_prefix("wait for cloud backup write supervisor", CloudBackupError::Internal)?; + let result = receiver.await.map_err(|source| { + CloudBackupError::internal_context("wait for cloud backup write supervisor", source) + })?; let context = result.context(); let context_id = context.id(); if context.origin() != self.origin { return Err(CloudBackupError::Internal(format!( "cloud backup write supervisor returned mismatched operation origin for command {context_id:?}", - ))); + ) + .into())); } result.into_result() @@ -75,7 +76,38 @@ impl CloudBackupWriteClient { call!(self.supervisor.upload_wallet_backup(cloud, namespace, record_id, data)).await } } - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; + + self.await_result(receiver).await + } + + pub(crate) async fn upload_wallet_backup_with_completion( + &self, + cloud: CloudStorageClient, + namespace: String, + record_id: String, + data: Vec, + completion: CloudBackupWriteCompletion, + ) -> Result<(), CloudBackupError> { + let receiver = match self.origin { + Some(origin) => { + call!(self.supervisor.upload_wallet_backup_with_completion_for_operation( + cloud, namespace, record_id, data, completion, origin + )) + .await + } + None => { + call!(self.supervisor.upload_wallet_backup_with_completion( + cloud, namespace, record_id, data, completion + )) + .await + } + } + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; self.await_result(receiver).await } @@ -96,7 +128,9 @@ impl CloudBackupWriteClient { self.supervisor.upload_master_key_backup_for_operation(cloud, namespace, data, origin) ) .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; self.await_result(receiver).await } @@ -119,7 +153,9 @@ impl CloudBackupWriteClient { cloud, namespace, data, completion, origin )) .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; self.await_result(receiver).await } @@ -145,7 +181,108 @@ impl CloudBackupWriteClient { origin )) .await - .map_err_prefix("start cloud backup write supervisor", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; + + self.await_result(receiver).await + } + + pub(crate) async fn complete_uploaded_wallet_batch( + &self, + cloud: CloudStorageClient, + namespace_id: String, + uploaded_wallets: Vec, + count_refresh: CloudBackupWalletCountRefresh, + ) -> Result<(), CloudBackupError> { + let receiver = match self.origin { + Some(origin) => { + call!(self.supervisor.complete_uploaded_wallet_batch_for_operation( + cloud, + namespace_id, + uploaded_wallets, + count_refresh, + origin + )) + .await + } + None => { + call!(self.supervisor.complete_uploaded_wallet_batch( + cloud, + namespace_id, + uploaded_wallets, + count_refresh + )) + .await + } + } + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; + + self.await_result(receiver).await + } + + pub(crate) async fn delete_active_wallet_backup( + &self, + cloud: CloudStorageClient, + namespace: String, + record_id: String, + ) -> Result<(), CloudBackupError> { + let Some(origin) = self.origin else { + return Err(CloudBackupError::Internal( + "cloud backup active-wallet delete requires an operation origin".into(), + )); + }; + + let receiver = call!( + self.supervisor + .delete_active_wallet_backup_for_operation(cloud, namespace, record_id, origin) + ) + .await + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; + + self.await_result(receiver).await + } + + pub(crate) async fn delete_namespace( + &self, + cloud: CloudStorageClient, + namespace: String, + ) -> Result<(), CloudBackupError> { + let Some(origin) = self.origin else { + return Err(CloudBackupError::Internal( + "cloud backup namespace delete requires an operation origin".into(), + )); + }; + + let receiver = + call!(self.supervisor.delete_namespace_for_operation(cloud, namespace, origin)) + .await + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; + + self.await_result(receiver).await + } + + pub(crate) async fn apply_completion( + &self, + completion: CloudBackupWriteCompletion, + ) -> Result<(), CloudBackupError> { + let Some(origin) = self.origin else { + return Err(CloudBackupError::Internal( + "cloud backup write completion requires an operation origin".into(), + )); + }; + + let receiver = call!(self.supervisor.apply_completion_for_operation(completion, origin)) + .await + .map_err(|source| { + CloudBackupError::internal_context("start cloud backup write supervisor", source) + })?; self.await_result(receiver).await } diff --git a/rust/src/manager/cloud_backup_manager/actors/write/supervisor.rs b/rust/src/manager/cloud_backup_manager/actors/write/supervisor.rs index f2ac5f2fe..fb822a45b 100644 --- a/rust/src/manager/cloud_backup_manager/actors/write/supervisor.rs +++ b/rust/src/manager/cloud_backup_manager/actors/write/supervisor.rs @@ -433,10 +433,13 @@ impl CloudBackupWriteSupervisor { }; if active.context != context { - active.complete(Err(CloudBackupError::Internal(format!( - "cloud backup write supervisor received mismatched completion for command {:?}", - context.id() - )))); + active.complete(Err(CloudBackupError::Internal( + format!( + "cloud backup write supervisor received mismatched completion for command {:?}", + context.id() + ) + .into(), + ))); self.start_next_pending_write(); self.complete_drain_waiters_if_idle(); return Produces::ok(()); @@ -634,6 +637,25 @@ impl CloudBackupWriteSupervisor { Produces::ok(receiver) } + pub(crate) async fn upload_wallet_backup_with_completion_for_operation( + &mut self, + cloud: CloudStorageClient, + namespace: String, + record_id: String, + data: Vec, + completion: CloudBackupWriteCompletion, + origin: CloudBackupExclusiveOperationClaim, + ) -> ActorResult> { + let command = self.advance_operation_command_id(origin); + let receiver = self.submit_write( + CloudBackupWriteAdmission::RequiresWritesAllowed, + CloudBackupRemoteWriteCommand::UploadWallet { cloud, namespace, record_id, data }, + CloudBackupWriteLocalCompletion::Apply(completion), + command, + ); + Produces::ok(receiver) + } + pub(crate) async fn upload_master_key_backup_for_operation( &mut self, cloud: CloudStorageClient, @@ -757,6 +779,31 @@ impl CloudBackupWriteSupervisor { Produces::ok(receiver) } + pub(crate) async fn complete_uploaded_wallet_batch_for_operation( + &mut self, + cloud: CloudStorageClient, + namespace_id: String, + uploaded_wallets: Vec, + count_refresh: CloudBackupWalletCountRefresh, + origin: CloudBackupExclusiveOperationClaim, + ) -> ActorResult> { + let command = self.advance_operation_command_id(origin); + let receiver = self.submit_write( + CloudBackupWriteAdmission::RequiresWritesAllowed, + CloudBackupRemoteWriteCommand::ListWalletCountOptional { + cloud, + namespace_id: namespace_id.clone(), + }, + CloudBackupWriteLocalCompletion::CompleteUploadedWalletBatch { + namespace_id, + uploaded_wallets, + count_refresh, + }, + command, + ); + Produces::ok(receiver) + } + pub(crate) async fn apply_completion_for_operation( &mut self, completion: CloudBackupWriteCompletion, diff --git a/rust/src/manager/cloud_backup_manager/detail.rs b/rust/src/manager/cloud_backup_manager/detail.rs index 9546e0286..211ece2ae 100644 --- a/rust/src/manager/cloud_backup_manager/detail.rs +++ b/rust/src/manager/cloud_backup_manager/detail.rs @@ -6,21 +6,20 @@ use super::{ CLOUD_BACKUP_MANAGER, CloudBackupDetail, CloudBackupManagerAction, CloudBackupPasskeyChoiceIntent, CloudBackupRestoreFlow, CloudBackupStateReducerEvent, CloudBackupWalletItem, DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, - OtherBackupsOperation, RustCloudBackupManager, SavedPasskeyConfirmationMode, - actors::CloudBackupOperation, + OtherBackupsOperation, RustCloudBackupManager, }; type Action = CloudBackupManagerAction; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum RecoveryAction { +pub(crate) enum RecoveryAction { RecreateManifest, ReinitializeBackup, RepairPasskey, } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum VerificationState { +pub(crate) enum VerificationState { Idle, Verifying, Verified(DeepVerificationReport), @@ -70,21 +69,21 @@ pub enum CloudBackupVerificationPresentation { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PendingUploadVerificationState { +pub(crate) enum PendingUploadVerificationState { Idle, Confirming, BlockedOnAuthorization, } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum SyncState { +pub(crate) enum SyncState { Idle, Syncing, Failed(String), } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum RecoveryState { +pub(crate) enum RecoveryState { Idle, Recovering(RecoveryAction), Failed { action: RecoveryAction, error: String }, @@ -106,13 +105,6 @@ pub enum CloudOnlyOperation { Failed { error: String }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CloudBackupSyncOutcome { - Started, - Completed, - Failed(String), -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CloudBackupCloudOnlyFetchOutcome { Reset, @@ -146,17 +138,6 @@ pub(crate) enum CloudBackupOtherBackupsOutcome { Failed(String), } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CloudBackupEnableOutcome { - ProgressCleared, - ReturnedToIdle, - CreatingPasskey, - WaitingForPasskeyAvailability, - UploadingBackup, - ConfirmingSavedPasskey, - AwaitingSavedPasskeyConfirmation(SavedPasskeyConfirmationMode), -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CloudBackupRestoreOutcome { ProgressCleared, @@ -169,46 +150,6 @@ pub(crate) enum CloudBackupDetailOutcome { Refreshed(CloudBackupDetail), } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CloudBackupVerificationOutcome { - Idle, - Started, - Verified(DeepVerificationReport), - PasskeyConfirmed, - Failed(DeepVerificationFailure), - Cancelled, -} - -impl CloudBackupVerificationOutcome { - pub(crate) fn from_state(state: VerificationState) -> Self { - match state { - VerificationState::Idle => Self::Idle, - VerificationState::Verifying => Self::Started, - VerificationState::Verified(report) => Self::Verified(report), - VerificationState::PasskeyConfirmed => Self::PasskeyConfirmed, - VerificationState::Failed(failure) => Self::Failed(failure), - VerificationState::Cancelled => Self::Cancelled, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CloudBackupRecoveryOutcome { - Idle, - Started(RecoveryAction), - Failed { action: RecoveryAction, error: String }, -} - -impl CloudBackupRecoveryOutcome { - pub(crate) fn from_state(state: RecoveryState) -> Self { - match state { - RecoveryState::Idle => Self::Idle, - RecoveryState::Recovering(action) => Self::Started(action), - RecoveryState::Failed { action, error } => Self::Failed { action, error }, - } - } -} - #[uniffi::export] impl RustCloudBackupManager { #[uniffi::method] @@ -317,31 +258,27 @@ impl RustCloudBackupManager { } fn spawn_recovery(self: std::sync::Arc, action: RecoveryAction) { - let operation = CloudBackupOperation::Recovery(action); - send!(self.supervisor.start_operation(operation, None)); + send!(self.supervisor.start_recovery_operation(action)); } fn spawn_repair_passkey(self: std::sync::Arc, no_discovery: bool) { - let operation = CloudBackupOperation::RepairPasskey { no_discovery }; - send!(self.supervisor.start_operation(operation, None)); + send!(self.supervisor.start_repair_passkey_operation(no_discovery)); } fn spawn_sync(self: std::sync::Arc) { - send!(self.supervisor.start_operation(CloudBackupOperation::Sync, None)); + send!(self.supervisor.start_sync_operation()); } fn spawn_fetch_cloud_only(self: std::sync::Arc) { - send!(self.supervisor.start_operation(CloudBackupOperation::FetchCloudOnly, None)); + send!(self.supervisor.start_cloud_only_fetch_request()); } fn spawn_restore_cloud_wallet(self: std::sync::Arc, record_id: super::RecordId) { - let operation = CloudBackupOperation::RestoreCloudWallet; - send!(self.supervisor.start_operation(operation, Some(record_id.into()))); + send!(self.supervisor.start_restore_cloud_wallet_operation(record_id.into())); } fn spawn_delete_cloud_wallet(self: std::sync::Arc, record_id: super::RecordId) { - let operation = CloudBackupOperation::DeleteCloudWallet; - send!(self.supervisor.start_operation(operation, Some(record_id.into()))); + send!(self.supervisor.start_delete_cloud_wallet_operation(record_id.into())); } fn spawn_recover_other_backups(self: std::sync::Arc) { @@ -355,7 +292,7 @@ impl RustCloudBackupManager { return; } - send!(self.supervisor.start_operation(CloudBackupOperation::RecoverOtherBackups, None)); + send!(self.supervisor.start_recover_other_backups_operation()); } fn spawn_delete_other_backups(self: std::sync::Arc) { @@ -369,7 +306,7 @@ impl RustCloudBackupManager { return; } - send!(self.supervisor.start_operation(CloudBackupOperation::DeleteOtherBackups, None)); + send!(self.supervisor.start_delete_other_backups_operation()); } fn spawn_refresh_detail(self: std::sync::Arc) { @@ -407,24 +344,24 @@ impl RustCloudBackupManager { if let Some(detail) = detail { self.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(detail)); } - self.apply_verification_outcome(CloudBackupVerificationOutcome::PasskeyConfirmed); + self.apply_verification_state(VerificationState::PasskeyConfirmed); } DeepVerificationResult::PasskeyMissing(detail) => { if let Some(detail) = detail { self.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(detail)); } - self.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - self.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + self.apply_verification_state(VerificationState::Idle); + self.apply_recovery_state(RecoveryState::Idle); } DeepVerificationResult::UserCancelled(detail) => { if let Some(detail) = detail { self.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(detail)); } - self.apply_verification_outcome(CloudBackupVerificationOutcome::Cancelled); + self.apply_verification_state(VerificationState::Cancelled); } DeepVerificationResult::NotEnabled => { - self.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - self.apply_recovery_outcome(CloudBackupRecoveryOutcome::Idle); + self.apply_verification_state(VerificationState::Idle); + self.apply_recovery_state(RecoveryState::Idle); } DeepVerificationResult::Failed(failure) => { self.apply_failed_verification(failure); diff --git a/rust/src/manager/cloud_backup_manager/dto.rs b/rust/src/manager/cloud_backup_manager/dto.rs new file mode 100644 index 000000000..f03e1b029 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/dto.rs @@ -0,0 +1,338 @@ +use cove_types::network::Network; + +use crate::database::cloud_backup::PersistedCloudBackupState; +use crate::wallet::metadata::{WalletMode as LocalWalletMode, WalletType}; + +use super::CloudBackupVerificationSource; + +/// Shared settings row state projected for Swift and Kotlin presentation +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupSettingsRowStatus { + Disabled, + Disabling, + SettingUp, + Restoring, + Active, + PasskeyMissing, + PasskeyProviderUnsupported, + Unverified, + Confirming, + VerificationRecommended, + CheckingSync, + Syncing, + NoFiles, + DriveUnavailable, + Error(String), + AuthorizationRequired(String), +} + +/// Whether saved passkey confirmation was user-triggered or flow-triggered +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum SavedPasskeyConfirmationMode { + Automatic, + Manual, +} + +/// Context carried through enable so prompts and verification attribution stay stable +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Record)] +pub struct CloudBackupEnableContext { + pub saved_passkey_confirmation: SavedPasskeyConfirmationMode, + pub verification_source: CloudBackupVerificationSource, +} + +impl CloudBackupEnableContext { + pub(crate) fn settings_manual() -> Self { + Self { + saved_passkey_confirmation: SavedPasskeyConfirmationMode::Manual, + verification_source: CloudBackupVerificationSource::Settings, + } + } +} + +/// Prompt intent for choosing between an existing passkey and a new one +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupPasskeyChoiceIntent { + Enable(CloudBackupEnableContext, Option), + RepairPasskey, +} + +/// User selection for the currently visible enable prompt +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupEnablePromptChoice { + UseExisting, + CreateNew, +} + +/// Root-level prompt the UI should show for the current cloud backup state +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupRootPrompt { + None, + ExistingBackupFound(CloudBackupEnableContext, Option), + PasskeyChoice(CloudBackupPasskeyChoiceIntent), + MissingPasskeyReminder, + Verification, +} + +/// User intent routed from Swift or Kotlin into the Rust cloud backup manager +#[derive(Debug, Clone)] +pub enum CloudBackupManagerAction { + EnableCloudBackup(CloudBackupEnableContext), + EnableCloudBackupForceNew(CloudBackupEnableContext), + EnableCloudBackupNoDiscovery(CloudBackupEnableContext), + ConfirmSavedPasskey, + DiscardPendingEnableCloudBackup, + DismissPasskeyChoicePrompt, + DismissMissingPasskeyReminder, + RestoreFromCloudBackup, + CancelRestore, + StartVerification(CloudBackupVerificationSource), + StartVerificationDiscoverable(CloudBackupVerificationSource), + DismissVerificationPrompt, + RecreateManifest, + ReinitializeBackup, + RepairPasskey, + RepairPasskeyNoDiscovery, + SyncUnsynced, + FetchCloudOnly, + RestoreCloudWallet(RecordId), + DeleteCloudWallet(RecordId), + RecoverOtherBackups, + DeleteOtherBackups, + DisableCloudBackup, + KeepCloudBackupEnabled, + RefreshDetail, + EnterDetail, + PromptEnablePasskeyChoice(CloudBackupEnableContext), + AcceptEnablePrompt(CloudBackupEnablePromptChoice), +} + +/// Restore summary shown after cloud backup onboarding restore completes +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CloudBackupRestoreReport { + pub wallets_restored: u32, + pub wallets_failed: u32, + pub failed_wallet_errors: Vec, + pub labels_failed_wallet_names: Vec, + pub labels_failed_errors: Vec, +} + +/// Completed and total counts for long-running cloud backup work +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Record)] +pub struct CloudBackupProgress { + pub completed: u32, + pub total: u32, +} + +/// Cloud backup record identifier exposed through UniFFI as an opaque string +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::From, derive_more::Into)] +pub struct RecordId(String); + +uniffi::custom_newtype!(RecordId, String); + +/// Per-wallet cloud backup sync state shown in backup detail +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupWalletStatus { + Dirty, + Uploading, + UploadedPendingConfirmation, + Confirmed, + Failed, + DeletedFromDevice, + UnsupportedVersion, + RemoteStateUnknown, +} + +/// Wallet row in cloud backup detail, combining local wallet metadata and sync state +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Record)] +pub struct CloudBackupWalletItem { + pub name: String, + pub network: Option, + pub wallet_mode: Option, + pub wallet_type: Option, + pub fingerprint: Option, + pub label_count: Option, + pub backup_updated_at: Option, + pub sync_status: CloudBackupWalletStatus, + /// Deterministic cloud record ID for the wallet backup represented by this item + pub record_id: String, +} + +/// Backup detail grouped by local wallet sync status and remote-only inventory +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CloudBackupDetail { + pub last_sync: Option, + pub up_to_date: Vec, + pub needs_sync: Vec, + /// Number of wallets in the cloud that aren't on this device + pub cloud_only_count: u32, + pub other_backups: CloudBackupOtherBackupsState, +} + +/// Summary state for backup namespaces that do not match the active device +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum CloudBackupOtherBackupsState { + Loaded { summary: CloudBackupOtherBackupsSummary }, + LoadFailed { error: String }, +} + +/// Aggregate count of recoverable backup data in other namespaces +#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)] +pub struct CloudBackupOtherBackupsSummary { + pub namespace_count: u32, + pub wallet_count: u32, + pub passkey_hints: Vec, +} + +/// User-facing passkey hint that avoids exposing credential bytes +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Record)] +pub struct CloudBackupPasskeyHint { + pub provider_name: Option, + pub name_suffix: String, + pub registered_at: u64, +} + +impl CloudBackupPasskeyHint { + pub(crate) fn from_provider_hint(hint: &cove_cspp::backup_data::PasskeyProviderHint) -> Self { + Self { + provider_name: hint.known_provider().map(|provider| provider.display_name().into()), + name_suffix: hint.name_suffix.clone(), + registered_at: hint.registered_at, + } + } +} + +/// Operation state for recovering or deleting other backup namespaces +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum OtherBackupsOperation { + Idle, + Recovering, + Recovered { wallets_restored: u32, wallets_failed: u32, failed_wallet_errors: Vec }, + Deleting, + Deleted, + Failed { error: String }, +} + +/// Outcome of deep verification before projection into UI state +#[derive(Debug, Clone, uniffi::Enum)] +pub enum DeepVerificationResult { + Verified(DeepVerificationReport), + AwaitingUploadConfirmation(DeepVerificationReport), + PasskeyConfirmed(Option), + PasskeyMissing(Option), + UserCancelled(Option), + NotEnabled, + Failed(DeepVerificationFailure), +} + +/// Counts and repairs observed during a deep verification pass +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct DeepVerificationReport { + /// Cloud master key PRF wrapping was repaired + pub master_key_wrapper_repaired: bool, + /// Local keychain was repaired from verified cloud master key + pub local_master_key_repaired: bool, + /// credential_id was recovered via discoverable auth + pub credential_recovered: bool, + pub wallets_verified: u32, + pub wallets_failed: u32, + /// Wallet backups with unsupported version (newer format, skipped) + pub wallets_unsupported: u32, + /// May be None if wallet list was missing but master key verified + pub detail: Option, +} + +/// Persisted verification metadata projected into prompts and detail state +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupVerificationMetadata { + NotConfigured, + ConfiguredNeverVerified, + Verified(u64), + NeedsVerification, +} + +impl From<&PersistedCloudBackupState> for CloudBackupVerificationMetadata { + fn from(db_state: &PersistedCloudBackupState) -> Self { + if db_state.is_unverified() { + return Self::NeedsVerification; + } + + if !db_state.is_configured() { + return Self::NotConfigured; + } + + match db_state.last_verified_at() { + Some(last_verified_at) => Self::Verified(last_verified_at), + None => Self::ConfiguredNeverVerified, + } + } +} + +/// Trust failure that tells the UI which recovery path is valid +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeepVerificationFailure { + /// Transient iCloud/network/passkey error — safe to retry + Retry { + message: String, + detail: Option, + retry_action: Option, + }, + /// Manifest missing, master key verified intact — recreate from local wallets + RecreateManifest { message: String, warning: String, detail: Option }, + /// No verified cloud or local master key available — full re-enable needed + ReinitializeBackup { message: String, warning: String, detail: Option }, + /// Backup uses a newer format — do not overwrite + UnsupportedVersion { message: String, detail: Option }, +} + +impl DeepVerificationFailure { + pub(crate) fn retry( + message: impl Into, + detail: Option, + retry_action: Option, + ) -> Self { + Self::Retry { message: message.into(), detail, retry_action } + } + + pub(crate) fn detail(&self) -> Option<&CloudBackupDetail> { + match self { + Self::Retry { detail, .. } + | Self::RecreateManifest { detail, .. } + | Self::ReinitializeBackup { detail, .. } + | Self::UnsupportedVersion { detail, .. } => detail.as_ref(), + } + } + + pub(crate) fn is_connectivity_retry(&self) -> bool { + matches!(self, Self::Retry { retry_action: Some(_), .. }) + } + + pub(crate) fn connectivity_retry_action(&self) -> Option { + match self { + Self::Retry { retry_action: Some(action), .. } => Some(*action), + _ => None, + } + } +} + +/// Retry action the UI should dispatch for a retryable verification failure +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum CloudBackupRetryAction { + Verify, + VerifyDiscoverable, +} + +/// Top-level state snapshot exposed to platform managers +#[derive(Debug, Clone)] +pub struct CloudBackupState { + pub lifecycle: super::CloudBackupLifecycle, + pub settings_row_status: CloudBackupSettingsRowStatus, +} + +impl Default for CloudBackupState { + fn default() -> Self { + Self { + lifecycle: super::CloudBackupLifecycle::Disabled, + settings_row_status: CloudBackupSettingsRowStatus::Disabled, + } + } +} diff --git a/rust/src/manager/cloud_backup_manager/error.rs b/rust/src/manager/cloud_backup_manager/error.rs index 0eb0f7583..eda6e1e49 100644 --- a/rust/src/manager/cloud_backup_manager/error.rs +++ b/rust/src/manager/cloud_backup_manager/error.rs @@ -1,13 +1,149 @@ -use cove_device::cloud_storage::CloudStorageError; +use std::{error::Error as StdError, fmt, ops::Deref}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CloudStorageIssue { - AuthorizationRequired, - Offline, - Unavailable, - NotFound, - QuotaExceeded, - Other, +use cove_device::{cloud_storage::CloudStorageError, passkey::PasskeyError}; + +use crate::database::cloud_backup::CloudStorageIssue; + +#[derive(Debug)] +struct CloudBackupErrorSource { + message: String, + source: Option>, +} + +impl CloudBackupErrorSource { + fn message(message: impl Into) -> Self { + Self { message: message.into(), source: None } + } + + fn source(source: impl StdError + Send + Sync + 'static) -> Self { + Self { message: source.to_string(), source: Some(Box::new(source)) } + } + + pub(crate) fn context( + context: impl fmt::Display, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + let message = format!("{context}: {source}"); + + Self { message, source: Some(Box::new(source)) } + } +} + +impl fmt::Display for CloudBackupErrorSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.message.fmt(f) + } +} + +impl StdError for CloudBackupErrorSource { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source.as_deref().map(|source| source as &(dyn StdError + 'static)) + } +} + +impl Deref for CloudBackupErrorSource { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.message + } +} + +impl PartialEq<&str> for CloudBackupErrorSource { + fn eq(&self, other: &&str) -> bool { + self.message == *other + } +} + +impl PartialEq for CloudBackupErrorSource { + fn eq(&self, other: &str) -> bool { + self.message == other + } +} + +impl PartialEq for CloudBackupErrorSource { + fn eq(&self, other: &String) -> bool { + self.message == *other + } +} + +macro_rules! cloud_backup_source_error { + ($name:ident) => { + #[derive(Debug)] + pub(crate) struct $name(CloudBackupErrorSource); + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + + impl StdError for $name { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.0.source() + } + } + + impl Deref for $name { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl PartialEq<&str> for $name { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } + } + + impl PartialEq for $name { + fn eq(&self, other: &str) -> bool { + self.0 == *other + } + } + + impl PartialEq for $name { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } + } + + impl From for $name { + fn from(message: String) -> Self { + Self(CloudBackupErrorSource::message(message)) + } + } + + impl From<&str> for $name { + fn from(message: &str) -> Self { + Self(CloudBackupErrorSource::message(message)) + } + } + }; +} + +cloud_backup_source_error!(CloudBackupPasskeyError); +cloud_backup_source_error!(CloudBackupCryptoError); +cloud_backup_source_error!(CloudBackupInternalError); + +impl CloudBackupCryptoError { + pub(crate) fn context( + context: impl fmt::Display, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self(CloudBackupErrorSource::context(context, source)) + } +} + +impl CloudBackupInternalError { + pub(crate) fn context( + context: impl fmt::Display, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self(CloudBackupErrorSource::context(context, source)) + } } pub(crate) fn is_connectivity_related_issue(issue: impl Into) -> bool { @@ -138,10 +274,10 @@ pub(crate) enum CloudBackupError { RecoveryRequired(String), #[error("passkey error: {0}")] - Passkey(String), + Passkey(#[source] CloudBackupPasskeyError), #[error("crypto error: {0}")] - Crypto(String), + Crypto(#[source] CloudBackupCryptoError), #[error("cloud storage error: {0}")] Cloud(String), @@ -159,7 +295,7 @@ pub(crate) enum CloudBackupError { Deferred(String), #[error("internal error: {0}")] - Internal(String), + Internal(#[source] CloudBackupInternalError), #[error("compatibility error: {0}")] Compatibility(String), @@ -178,6 +314,32 @@ pub(crate) enum CloudBackupError { } impl CloudBackupError { + pub(crate) fn passkey(source: impl StdError + Send + Sync + 'static) -> Self { + Self::Passkey(CloudBackupPasskeyError(CloudBackupErrorSource::source(source))) + } + + pub(crate) fn crypto(source: impl StdError + Send + Sync + 'static) -> Self { + Self::Crypto(CloudBackupCryptoError(CloudBackupErrorSource::source(source))) + } + + pub(crate) fn crypto_context( + context: impl fmt::Display, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self::Crypto(CloudBackupCryptoError::context(context, source)) + } + + pub(crate) fn internal(source: impl StdError + Send + Sync + 'static) -> Self { + Self::Internal(CloudBackupInternalError(CloudBackupErrorSource::source(source))) + } + + pub(crate) fn internal_context( + context: impl fmt::Display, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self::Internal(CloudBackupInternalError::context(context, source)) + } + pub(crate) fn cloud_storage_context( context: impl Into, source: CloudStorageError, @@ -189,3 +351,39 @@ impl CloudBackupError { matches!(self, Self::Cloud(_) | Self::CloudStorage(_) | Self::CloudStorageContext { .. }) } } + +impl From for CloudBackupError { + fn from(error: CloudBackupPasskeyError) -> Self { + Self::Passkey(error) + } +} + +impl From for CloudBackupError { + fn from(error: PasskeyError) -> Self { + Self::passkey(error) + } +} + +impl From for CloudBackupError { + fn from(error: CloudBackupCryptoError) -> Self { + Self::Crypto(error) + } +} + +impl From for CloudBackupError { + fn from(error: cove_cspp::CsppError) -> Self { + Self::crypto(error) + } +} + +impl From for CloudBackupError { + fn from(error: CloudBackupInternalError) -> Self { + Self::Internal(error) + } +} + +impl From for CloudBackupError { + fn from(error: serde_json::Error) -> Self { + Self::internal(error) + } +} diff --git a/rust/src/manager/cloud_backup_manager/model.rs b/rust/src/manager/cloud_backup_manager/model.rs index 610767044..779f94c62 100644 --- a/rust/src/manager/cloud_backup_manager/model.rs +++ b/rust/src/manager/cloud_backup_manager/model.rs @@ -5,12 +5,12 @@ //! claims are tracked here so stale async completions cannot clear newer work use super::{ - CloudBackupDetail, CloudBackupDisableOutcome, CloudBackupEnableContext, - CloudBackupEnablePromptChoice, CloudBackupEnableState, CloudBackupPasskeyChoiceIntent, - CloudBackupRootPrompt, CloudBackupSettingsRowStatus, CloudBackupStatus, + CloudBackupDetail, CloudBackupEnableContext, CloudBackupEnablePromptChoice, CloudBackupError, + CloudBackupPasskeyChoiceIntent, CloudBackupRootPrompt, CloudBackupSettingsRowStatus, CloudBackupVerificationMetadata, CloudBackupVerificationPresentation, CloudBackupVerificationReason, CloudOnlyOperation, CloudOnlyState, OtherBackupsOperation, PendingUploadVerificationState, RecoveryAction, RecoveryState, SyncState, VerificationState, + is_connectivity_related_issue, }; use super::verify::coordinator::CloudBackupVerificationCoordinator; @@ -35,6 +35,51 @@ pub use self::state_types::{ const PENDING_UPLOAD_AUTHORIZATION_BLOCKED_MESSAGE: &str = "cloud authorization required"; const STALE_VERIFICATION_THRESHOLD: Duration = Duration::from_secs(60 * 60 * 24 * 30); +/// Runtime cloud backup status projected from persisted and in-process state +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub(crate) enum CloudBackupStatus { + Disabled, + Disabling, + Enabling, + Restoring, + Enabled, + PasskeyMissing, + UnsupportedPasskeyProvider, + Error(String), +} + +/// Internal enable status before projection into the public lifecycle +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub(crate) enum CloudBackupEnableState { + Idle, + CreatingPasskey, + WaitingForPasskeyAvailability, + AwaitingSavedPasskeyConfirmation(super::SavedPasskeyConfirmationMode), + ConfirmingSavedPasskey, + UploadingBackup, +} + +/// Result of a disable attempt after the supervisor resolves remote and local work +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CloudBackupDisableOutcome { + Started, + ReturnedToIdle, + Failed { message: String, can_keep_enabled: bool }, +} + +/// Remote detail fetch result that keeps access errors distinguishable from failed detail state +#[derive(Debug)] +pub(crate) enum CloudBackupDetailResult { + Success(CloudBackupDetail), + AccessError(CloudBackupError), +} + +impl CloudBackupDetailResult { + pub(crate) fn is_connectivity_access_error(&self) -> bool { + matches!(self, Self::AccessError(error) if is_connectivity_related_issue(error)) + } +} + /// Private reducer wrapper that projects public Cloud Backup state #[derive(Debug, Clone, Default)] pub(crate) struct CloudBackupStateReducer { @@ -1167,7 +1212,40 @@ impl CloudBackupStateReducer { #[cfg(test)] pub(crate) mod test_support { use super::*; - use crate::manager::cloud_backup_manager::test_support::CloudBackupModelSnapshot; + use crate::manager::cloud_backup_manager::CloudBackupProgress; + + #[derive(Debug, Clone)] + pub(crate) struct CloudBackupModelSnapshot { + pub(crate) root_prompt: CloudBackupRootPrompt, + pub(crate) status: CloudBackupStatus, + pub(crate) sync_health: CloudSyncHealth, + pub(crate) progress: Option, + pub(crate) restore_progress: Option, + pub(crate) enable_state: CloudBackupEnableState, + pub(crate) pending_upload_verification: PendingUploadVerificationState, + pub(crate) verification_presentation: CloudBackupVerificationPresentation, + pub(crate) detail: Option, + pub(crate) verification: VerificationState, + } + + impl Default for CloudBackupModelSnapshot { + fn default() -> Self { + Self { + root_prompt: CloudBackupRootPrompt::None, + status: CloudBackupStatus::Disabled, + sync_health: CloudSyncHealth::Unknown, + progress: None, + restore_progress: None, + enable_state: CloudBackupEnableState::Idle, + pending_upload_verification: PendingUploadVerificationState::Idle, + verification_presentation: CloudBackupVerificationPresentation::Hidden { + source: None, + }, + detail: None, + verification: VerificationState::Idle, + } + } + } impl CloudBackupStateReducer { pub(crate) fn snapshot(&self) -> CloudBackupModelSnapshot { diff --git a/rust/src/manager/cloud_backup_manager/ops.rs b/rust/src/manager/cloud_backup_manager/ops.rs index 13396f188..8ef4a3872 100644 --- a/rust/src/manager/cloud_backup_manager/ops.rs +++ b/rust/src/manager/cloud_backup_manager/ops.rs @@ -1,5 +1,4 @@ use cove_device::cloud_storage::CloudStorageClient; -use cove_util::ResultExt as _; use tracing::info; use super::wallets::{DownloadedWalletBackup, WalletBackupLookup, WalletBackupReader}; @@ -11,7 +10,7 @@ use super::{ mod cloud_only; mod disable; mod enable; -mod other_backups; +mod other_backup_operations; mod restore; mod sync; @@ -52,9 +51,9 @@ where S: cove_cspp::CsppStore, S::Error: std::fmt::Display, { - let Some(master_key) = cspp - .load_master_key_from_store() - .map_err_prefix("loading master key from store", CloudBackupError::Internal)? + let Some(master_key) = cspp.load_master_key_from_store().map_err(|source| { + CloudBackupError::internal_context("loading master key from store", source) + })? else { return Ok(None); }; @@ -88,7 +87,7 @@ where { let local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("load local master key", source))?; match local_master_key { Some(master_key) if master_key.namespace_id() == namespace => return Ok(master_key), @@ -106,7 +105,8 @@ where if recovered_namespace != namespace { return Err(CloudBackupError::Internal(format!( "recovered master key namespace mismatch: expected {namespace}, got {recovered_namespace}", - ))); + ) + .into())); } Ok(recovered) diff --git a/rust/src/manager/cloud_backup_manager/ops/cloud_only.rs b/rust/src/manager/cloud_backup_manager/ops/cloud_only.rs index 47f12b959..a0693dec1 100644 --- a/rust/src/manager/cloud_backup_manager/ops/cloud_only.rs +++ b/rust/src/manager/cloud_backup_manager/ops/cloud_only.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient}; use cove_device::keychain::Keychain; -use cove_util::ResultExt as _; use futures::stream::{self, StreamExt as _}; use tracing::{info, warn}; use zeroize::Zeroizing; @@ -164,7 +163,9 @@ impl RustCloudBackupManager { ); let existing_identities = crate::wallet_identity::collect_existing_wallet_identities() - .map_err_prefix("collect wallet identities", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("collect wallet identities", source) + })?; let mut restore_session = WalletRestoreSession::new(existing_identities); let outcome = restore_session .restore_record(&reader, record_id) diff --git a/rust/src/manager/cloud_backup_manager/ops/disable.rs b/rust/src/manager/cloud_backup_manager/ops/disable.rs index 15fa4bc39..eb18a700b 100644 --- a/rust/src/manager/cloud_backup_manager/ops/disable.rs +++ b/rust/src/manager/cloud_backup_manager/ops/disable.rs @@ -4,7 +4,6 @@ use std::time::Duration; use backon::{BackoffBuilder as _, FibonacciBuilder}; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient, CloudStorageError}; -use cove_util::ResultExt as _; use super::blocking_cloud_error; use crate::database::Database; @@ -63,10 +62,12 @@ impl RustCloudBackupManager { Database::global() .cloud_backup_state .set(&PersistedCloudBackupState::Disabling(disabling.clone())) - .map_err_prefix( - "persist initial cloud backup disabling state", - CloudBackupError::Internal, - )?; + .map_err(|source| { + CloudBackupError::internal_context( + "persist initial cloud backup disabling state", + source, + ) + })?; disabling } PersistedCloudBackupState::Disabling(disabling) => disabling, @@ -230,7 +231,7 @@ impl RustCloudBackupManager { Database::global() .cloud_backup_state .set(&PersistedCloudBackupState::Disabling(disabling.clone())) - .map_err_prefix(context, CloudBackupError::Internal) + .map_err(|source| CloudBackupError::internal_context(context, source)) } pub(crate) fn persist_disabling_failure( @@ -261,24 +262,23 @@ impl RustCloudBackupManager { } pub(crate) fn persist_disabled_after_remote_delete(&self) -> Result<(), CloudBackupError> { - Database::global() - .cloud_backup_state - .set(&PersistedCloudBackupState::Disabled) - .map_err_prefix("persist disabled cloud backup state", CloudBackupError::Internal)?; + Database::global().cloud_backup_state.set(&PersistedCloudBackupState::Disabled).map_err( + |source| { + CloudBackupError::internal_context("persist disabled cloud backup state", source) + }, + )?; self.reconcile_runtime_status(CloudBackupStatus::Disabled); self.refresh_persisted_flags(); Ok(()) } pub(crate) fn finish_disable_local_cleanup(&self) -> Result<(), CloudBackupError> { - CloudBackupKeychain::global().clear_local_state().map_err_prefix( - "clear cloud backup local keychain state", - CloudBackupError::Internal, - )?; - Database::global() - .cloud_blob_sync_states - .delete_all() - .map_err_prefix("clear cloud backup blob sync state", CloudBackupError::Internal)?; + CloudBackupKeychain::global().clear_local_state().map_err(|source| { + CloudBackupError::internal_context("clear cloud backup local keychain state", source) + })?; + Database::global().cloud_blob_sync_states.delete_all().map_err(|source| { + CloudBackupError::internal_context("clear cloud backup blob sync state", source) + })?; self.apply_detail_outcome( crate::manager::cloud_backup_manager::CloudBackupDetailOutcome::Cleared, @@ -289,9 +289,7 @@ impl RustCloudBackupManager { self.apply_other_backups_outcome( crate::manager::cloud_backup_manager::CloudBackupOtherBackupsOutcome::Idle, ); - self.apply_recovery_outcome( - crate::manager::cloud_backup_manager::CloudBackupRecoveryOutcome::Idle, - ); + self.apply_recovery_state(crate::manager::cloud_backup_manager::RecoveryState::Idle); Ok(()) } @@ -359,7 +357,9 @@ impl RustCloudBackupManager { Database::global() .cloud_backup_state .set(&PersistedCloudBackupState::Configured(disabling.previous_configured.clone())) - .map_err_prefix("restore configured cloud backup state", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("restore configured cloud backup state", source) + })?; Ok(true) } diff --git a/rust/src/manager/cloud_backup_manager/ops/enable.rs b/rust/src/manager/cloud_backup_manager/ops/enable.rs index c43b1414b..9640cbddd 100644 --- a/rust/src/manager/cloud_backup_manager/ops/enable.rs +++ b/rust/src/manager/cloud_backup_manager/ops/enable.rs @@ -3,7 +3,6 @@ use cove_cspp::master_key_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient}; use cove_device::keychain::Keychain; use cove_device::passkey::PasskeyAccess; -use cove_util::ResultExt as _; use tracing::{info, warn}; use zeroize::Zeroizing; @@ -18,7 +17,7 @@ use crate::manager::cloud_backup_manager::wallets::{ WalletBackupLookup, WalletBackupReader, WalletRestoreOutcome, WalletRestoreSession, }; use crate::manager::cloud_backup_manager::{ - CloudBackupEnableContext, CloudBackupEnableOutcome, CloudBackupError, CloudBackupPasskeyHint, + CloudBackupEnableContext, CloudBackupEnableState, CloudBackupError, CloudBackupPasskeyHint, CloudBackupRestoreOutcome, CloudBackupStatus, CloudBackupStore, PendingEnableSession, PendingVerificationUpload, is_connectivity_related_issue, master_key_wrapper_revision_hash, }; @@ -225,8 +224,9 @@ impl RustCloudBackupManager { preparation: &CloudBackupEnableRecoveryPreparation, ) -> Result<(), CloudBackupError> { let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&preparation.active_master_key) - .map_err_prefix("save recovered master key", CloudBackupError::Internal) + cspp.save_master_key(&preparation.active_master_key).map_err(|source| { + CloudBackupError::internal_context("save recovered master key", source) + }) } pub(crate) fn rollback_enable_recovery_master_key(&self) { @@ -321,7 +321,9 @@ impl RustCloudBackupManager { namespaces: &[MergeNamespace], ) -> Result, CloudBackupError> { let existing_identities = crate::wallet_identity::collect_existing_wallet_identities() - .map_err_prefix("collect wallet identities", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("collect wallet identities", source) + })?; let mut restore_session = WalletRestoreSession::new(existing_identities); let mut merged_namespaces = Vec::with_capacity(namespaces.len()); @@ -419,7 +421,7 @@ impl RustCloudBackupManager { let has_local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + .map_err(|source| CloudBackupError::internal_context("load local master key", source))? .is_some(); if has_local_master_key { @@ -500,11 +502,11 @@ impl RustCloudBackupManager { let cspp = cove_cspp::Cspp::new(keychain.clone()); let had_local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + .map_err(|source| CloudBackupError::internal_context("load local master key", source))? .is_some(); let master_key = cspp .get_or_create_master_key() - .map_err_prefix("master key", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("master key", source))?; let namespace_id = master_key.namespace_id(); info!("Enable: namespace_id={namespace_id}, getting passkey"); @@ -556,7 +558,7 @@ impl RustCloudBackupManager { let cspp = cove_cspp::Cspp::new(keychain.clone()); let has_local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + .map_err(|source| CloudBackupError::internal_context("load local master key", source))? .is_some(); let existing_namespaces = if has_local_master_key { Vec::new() @@ -602,13 +604,13 @@ impl RustCloudBackupManager { let cspp = cove_cspp::Cspp::new(keychain.clone()); let had_local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + .map_err(|source| CloudBackupError::internal_context("load local master key", source))? .is_some(); info!("{log_context}: getting master key"); let master_key = cspp .get_or_create_master_key() - .map_err_prefix("master key", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("master key", source))?; let namespace_id = master_key.namespace_id(); info!("{log_context}: namespace_id={namespace_id}, creating passkey"); @@ -643,9 +645,9 @@ impl RustCloudBackupManager { let preserve_awaiting_prompt = matches!(status, CloudBackupStatus::Disabled) && self.state.read().is_awaiting_enable_prompt(); - self.apply_enable_outcome(CloudBackupEnableOutcome::ProgressCleared); + self.clear_enable_progress_report(); self.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); - self.apply_enable_outcome(CloudBackupEnableOutcome::ReturnedToIdle); + self.apply_enable_state(CloudBackupEnableState::Idle); if preserve_awaiting_prompt { self.reconcile_runtime_status(CloudBackupStatus::Enabling); } else { @@ -705,9 +707,9 @@ impl RustCloudBackupManager { ready.passkey.provider_hint.clone(), RemotePayloadMetadata::master_key(&namespace_id, uploaded_at), ) - .map_err_str(CloudBackupError::Crypto)?; + .map_err(CloudBackupError::crypto)?; let master_json = - serde_json::to_vec(&encrypted_master).map_err_str(CloudBackupError::Internal)?; + serde_json::to_vec(&encrypted_master).map_err(CloudBackupError::internal)?; let master_key_wrapper_revision = master_key_wrapper_revision_hash(&master_json); info!("Enable: uploading master key"); diff --git a/rust/src/manager/cloud_backup_manager/ops/other_backups.rs b/rust/src/manager/cloud_backup_manager/ops/other_backup_operations.rs similarity index 100% rename from rust/src/manager/cloud_backup_manager/ops/other_backups.rs rename to rust/src/manager/cloud_backup_manager/ops/other_backup_operations.rs diff --git a/rust/src/manager/cloud_backup_manager/ops/restore.rs b/rust/src/manager/cloud_backup_manager/ops/restore.rs index acba350f3..603897263 100644 --- a/rust/src/manager/cloud_backup_manager/ops/restore.rs +++ b/rust/src/manager/cloud_backup_manager/ops/restore.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use cove_device::cloud_storage::CloudStorageClient; -use cove_util::ResultExt as _; use tracing::{info, warn}; use zeroize::Zeroizing; @@ -23,7 +22,9 @@ impl RustCloudBackupManager { ) -> Result { let current_namespace = self.current_namespace_id()?; let existing_identities = crate::wallet_identity::collect_existing_wallet_identities() - .map_err_prefix("collect wallet identities", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("collect wallet identities", source) + })?; let mut restore_session = WalletRestoreSession::new(existing_identities); let mut current_wallet_record_ids: HashSet<_> = current_namespace_wallet_record_ids( cloud, diff --git a/rust/src/manager/cloud_backup_manager/ops/test_support.rs b/rust/src/manager/cloud_backup_manager/ops/test_support.rs index 405c3f760..dc1117fe2 100644 --- a/rust/src/manager/cloud_backup_manager/ops/test_support.rs +++ b/rust/src/manager/cloud_backup_manager/ops/test_support.rs @@ -24,14 +24,14 @@ use strum::IntoEnumIterator as _; use super::*; use crate::database::Database; use crate::database::cloud_backup::{ - CloudBlobDirtyState, CloudBlobFailedState, CloudBlobFailureIssue, CloudBlobUploadingState, + CloudBlobDirtyState, CloudBlobFailedState, CloudBlobUploadingState, CloudStorageIssue, PersistedBackupSyncState, PersistedBackupVerificationState, PersistedCloudBackupState, PersistedCloudBlobState, PersistedCloudBlobSyncState, PersistedConfiguredCloudBackup, PersistedDisablingCloudBackup, PersistedPasskeyState, }; use crate::manager::cloud_backup_manager::{ - CloudBackupKeychain, CloudBackupStore, PendingEnableSession, actors::restore::RestoreOperation, - pending::PendingUploadVerificationStatus, + CloudBackupKeychain, CloudBackupStore, PendingEnableSession, PendingUploadVerificationState, + actors::restore::RestoreOperation, }; use crate::manager::connectivity_manager::CONNECTIVITY_MANAGER; use crate::mnemonic::MnemonicExt as _; @@ -909,7 +909,7 @@ pub(crate) fn persist_failed_blob_state(wallet_id: WalletId, retryable: bool) { pub(crate) fn persist_failed_blob_state_with_issue( wallet_id: WalletId, retryable: bool, - issue: Option, + issue: Option, ) { let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); let record_id = cove_cspp::backup_data::wallet_record_id(wallet_id.as_ref()); @@ -1291,7 +1291,7 @@ pub(crate) async fn resume_wallet_uploads_from_persisted_state_for_test_async( pub(crate) async fn verify_pending_uploads_once_for_test_async( manager: &RustCloudBackupManager, ) -> bool { - !matches!(manager.verify_pending_uploads_once().await, PendingUploadVerificationStatus::Idle) + !matches!(manager.verify_pending_uploads_once().await, PendingUploadVerificationState::Idle) } pub(crate) async fn run_wallet_upload_for_test_async( diff --git a/rust/src/manager/cloud_backup_manager/ops/tests.rs b/rust/src/manager/cloud_backup_manager/ops/tests.rs index 5661114ac..0e5dae852 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests.rs @@ -19,16 +19,16 @@ use super::test_support::*; use super::*; use crate::database::Database; use crate::database::cloud_backup::{ - CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, CloudBlobFailureIssue, - CloudBlobUploadedPendingConfirmationState, CloudBlobUploadingState, PersistedCloudBackupState, - PersistedCloudBackupStatus, PersistedCloudBlobState, PersistedCloudBlobSyncState, - PersistedDisablingCloudBackup, + CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, + CloudBlobUploadedPendingConfirmationState, CloudBlobUploadingState, CloudStorageIssue, + PersistedCloudBackupState, PersistedCloudBackupStatus, PersistedCloudBlobState, + PersistedCloudBlobSyncState, PersistedDisablingCloudBackup, }; use crate::label_manager::LabelManager; use crate::manager::cloud_backup_manager::actors::{ - CloudBackupOperation, CloudBackupWriteClient, + CloudBackupWriteClient, cleanup::{CleanupExpectedWalletRecord, CleanupSourceNamespace, CloudBackupCleanupJob}, - supervisor::VerificationAttempt, + supervisor::{DeepVerificationContinuation, VerificationAttempt}, }; use crate::manager::cloud_backup_manager::model::{ CloudBackupDestructiveOperationState, CloudBackupExclusiveOperation, @@ -43,13 +43,12 @@ use crate::manager::cloud_backup_manager::wallets::{ }; use crate::manager::cloud_backup_manager::{ CLOUD_BACKUP_MANAGER, CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE, CloudBackupDetailResult, - CloudBackupDisableOutcome, CloudBackupEnableContext, CloudBackupEnableOutcome, - CloudBackupEnablePromptChoice, CloudBackupEnableState, CloudBackupKeychain, - CloudBackupLifecycle, CloudBackupManagerAction, CloudBackupOtherBackupsState, - CloudBackupPasskeyChoiceIntent, CloudBackupRestoreEvent, CloudBackupRootPrompt, - CloudBackupVerificationOutcome, CloudBackupVerificationPresentation, - CloudBackupVerificationReason, CloudBackupVerificationSource, CloudBackupWalletStatus, - DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, PendingEnableSession, + CloudBackupDisableOutcome, CloudBackupEnableContext, CloudBackupEnablePromptChoice, + CloudBackupEnableState, CloudBackupKeychain, CloudBackupLifecycle, CloudBackupManagerAction, + CloudBackupOtherBackupsState, CloudBackupPasskeyChoiceIntent, CloudBackupRestoreEvent, + CloudBackupRootPrompt, CloudBackupVerificationPresentation, CloudBackupVerificationReason, + CloudBackupVerificationSource, CloudBackupWalletStatus, DeepVerificationFailure, + DeepVerificationReport, DeepVerificationResult, PendingEnableSession, PendingUploadVerificationState, PendingVerificationCompletion, PendingVerificationUpload, RecoveryAction, SavedPasskeyConfirmationMode, VerificationState, }; @@ -97,9 +96,12 @@ async fn deep_verify_for_test( ) -> DeepVerificationResult { let step = manager.prepare_deep_verify_cloud_backup(force_discoverable).await; call!(manager.supervisor.complete_verification( + None, step, - force_discoverable, - VerificationAttempt::Initial + DeepVerificationContinuation::Manual { + force_discoverable, + attempt: VerificationAttempt::Initial, + } )) .await .unwrap(); @@ -117,7 +119,7 @@ async fn deep_verify_for_test( .await; if let Some(completion) = manager.pending_verification_completion() { - let mut report = completion.report().clone(); + let mut report = completion.report(); if report.detail.is_none() { report.detail = manager.model_snapshot().detail; } @@ -145,7 +147,7 @@ async fn enable_cloud_backup_create_new( ) -> Result<(), CloudBackupError> { run_enable_operation( manager, - CloudBackupOperation::Enable(CloudBackupEnableContext::settings_manual()), + TestEnableOperation::Enable(CloudBackupEnableContext::settings_manual()), ) .await } @@ -171,30 +173,50 @@ async fn enable_cloud_backup_force_new_with_context( manager: &RustCloudBackupManager, context: CloudBackupEnableContext, ) -> Result<(), CloudBackupError> { - run_enable_operation(manager, CloudBackupOperation::EnableForceNew(context)).await + run_enable_operation(manager, TestEnableOperation::ForceNew(context)).await } async fn enable_cloud_backup_no_discovery_with_context( manager: &RustCloudBackupManager, context: CloudBackupEnableContext, ) -> Result<(), CloudBackupError> { - run_enable_operation(manager, CloudBackupOperation::EnableNoDiscovery(context)).await + run_enable_operation(manager, TestEnableOperation::NoDiscovery(context)).await +} + +enum TestEnableOperation { + Enable(CloudBackupEnableContext), + ForceNew(CloudBackupEnableContext), + NoDiscovery(CloudBackupEnableContext), + ReinitializeBackup, } async fn run_enable_operation( manager: &RustCloudBackupManager, - operation: CloudBackupOperation, + operation: TestEnableOperation, ) -> Result<(), CloudBackupError> { let saved_passkey_confirmation = match &operation { - CloudBackupOperation::Enable(context) - | CloudBackupOperation::EnableForceNew(context) - | CloudBackupOperation::EnableNoDiscovery(context) => context.saved_passkey_confirmation, - _ => SavedPasskeyConfirmationMode::Manual, + TestEnableOperation::Enable(context) + | TestEnableOperation::ForceNew(context) + | TestEnableOperation::NoDiscovery(context) => context.saved_passkey_confirmation, + TestEnableOperation::ReinitializeBackup => SavedPasskeyConfirmationMode::Manual, }; - call!(manager.supervisor.start_operation(operation, None)) - .await - .expect("start enable operation"); + match operation { + TestEnableOperation::Enable(context) => { + call!(manager.supervisor.start_enable_operation(context)).await + } + TestEnableOperation::ForceNew(context) => { + call!(manager.supervisor.start_enable_force_new_operation(context)).await + } + TestEnableOperation::NoDiscovery(context) => { + call!(manager.supervisor.start_enable_no_discovery_operation(context)).await + } + TestEnableOperation::ReinitializeBackup => { + call!(manager.supervisor.start_recovery_operation(RecoveryAction::ReinitializeBackup)) + .await + } + } + .expect("start enable operation"); for _ in 0..200 { let Some(claim) = manager.projected_exclusive_operation() else { break }; @@ -216,7 +238,7 @@ async fn run_enable_operation( assert!(manager.projected_exclusive_operation().is_none(), "enable operation finishes"); match manager.current_status() { - CloudBackupStatus::Error(message) => Err(CloudBackupError::Internal(message)), + CloudBackupStatus::Error(message) => Err(CloudBackupError::Internal(message.into())), CloudBackupStatus::UnsupportedPasskeyProvider => { Err(CloudBackupError::UnsupportedPasskeyProvider) } @@ -224,6 +246,12 @@ async fn run_enable_operation( } } +async fn run_reinitialize_backup_operation( + manager: &RustCloudBackupManager, +) -> Result<(), CloudBackupError> { + run_enable_operation(manager, TestEnableOperation::ReinitializeBackup).await +} + async fn restore_from_local_master_key_fallback( cloud: &CloudStorageClient, store: &S, @@ -241,7 +269,9 @@ where crate::manager::cloud_backup_manager::keychain::CSPP_NAMESPACE_ID_KEY.into(), namespace_id.to_owned(), ) - .map_err_prefix("save namespace_id", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::Internal(format!("save namespace_id: {source}").into()) + })?; Ok((master_key, namespace_id)) } @@ -265,9 +295,7 @@ async fn wait_for_discover_count(globals: &TestGlobals, expected_count: usize) { } async fn run_disable_cloud_backup(manager: &Arc) { - call!(manager.supervisor.start_operation(CloudBackupOperation::Disable, None)) - .await - .expect("start disable operation"); + call!(manager.supervisor.start_disable_operation()).await.expect("start disable operation"); wait_for_test_condition(Duration::from_secs(2), "disable operation finishes", || { !matches!( manager.projected_exclusive_operation().map(|claim| claim.operation()), @@ -286,14 +314,9 @@ async fn run_keep_cloud_backup_enabled(manager: &Arc) { } async fn run_recreate_manifest(manager: &Arc) { - call!( - manager.supervisor.start_operation( - CloudBackupOperation::Recovery(RecoveryAction::RecreateManifest), - None - ) - ) - .await - .expect("start recreate-manifest operation"); + call!(manager.supervisor.start_recovery_operation(RecoveryAction::RecreateManifest)) + .await + .expect("start recreate-manifest operation"); wait_for_test_condition(Duration::from_secs(8), "recreate-manifest operation finishes", || { !matches!( manager.projected_exclusive_operation().map(|claim| claim.operation()), @@ -304,13 +327,9 @@ async fn run_recreate_manifest(manager: &Arc) { } async fn run_repair_passkey_operation(manager: &Arc, no_discovery: bool) { - call!( - manager - .supervisor - .start_operation(CloudBackupOperation::RepairPasskey { no_discovery }, None) - ) - .await - .expect("start repair-passkey operation"); + call!(manager.supervisor.start_repair_passkey_operation(no_discovery)) + .await + .expect("start repair-passkey operation"); wait_for_test_condition(Duration::from_secs(8), "repair-passkey operation finishes", || { !matches!( manager.projected_exclusive_operation().map(|claim| claim.operation()), @@ -413,7459 +432,16 @@ fn persist_pending_master_key_confirmation(namespace_id: String, revision_hash: .unwrap(); } -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_treats_missing_credential_as_no_match() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - globals.passkey.set_discover_result(Err(PasskeyError::NoCredentialFound)); - - let outcome = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[namespace]) - .await - .unwrap(); - - assert!(matches!(outcome, NamespaceMatchOutcome::NoMatch)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_treats_user_cancel_as_user_declined() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let outcome = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[namespace]) - .await - .unwrap(); - - assert!(matches!(outcome, NamespaceMatchOutcome::UserDeclined)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_mixed_supported_and_unsupported_versions_returns_no_match() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let supported_master_key = cove_cspp::master_key::MasterKey::generate(); - let supported_namespace = supported_master_key.namespace_id(); - let unsupported_master_key = cove_cspp::master_key::MasterKey::generate(); - let unsupported_namespace = unsupported_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&supported_master_key, &[7; 32], &[9; 32]) - .unwrap(); - let mut unsupported_master = cove_cspp::master_key_crypto::encrypt_master_key( - &unsupported_master_key, - &[7; 32], - &[9; 32], - ) - .unwrap(); - unsupported_master.version = 2; - - globals.cloud.set_master_key_backup( - supported_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.cloud.set_master_key_backup( - unsupported_namespace.clone(), - serde_json::to_vec(&unsupported_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: vec![8; 32], - credential_id: vec![1, 2, 3], - })); - - let outcome = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[supported_namespace, unsupported_namespace]) - .await - .unwrap(); - - assert!(matches!(outcome, NamespaceMatchOutcome::NoMatch)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_discovery_propagates_unsupported_provider() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); - - let result = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[namespace]) - .await; - let error = match result { - Ok(_) => panic!("expected unsupported passkey provider error"), - Err(error) => error, - }; - - assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_targeted_auth_propagates_unsupported_provider() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let first_master_key = cove_cspp::master_key::MasterKey::generate(); - let first_namespace = first_master_key.namespace_id(); - let second_master_key = cove_cspp::master_key::MasterKey::generate(); - let second_namespace = second_master_key.namespace_id(); - let first_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &[7; 32], &[9; 32]) - .unwrap(); - let second_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &[8; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - first_namespace.clone(), - serde_json::to_vec(&first_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - second_namespace.clone(), - serde_json::to_vec(&second_encrypted).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: vec![1; 32], - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Err(PasskeyError::PrfUnsupportedProvider)); - - let result = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[first_namespace, second_namespace]) - .await; - let error = match result { - Ok(_) => panic!("expected unsupported passkey provider error"), - Err(error) => error, - }; - - assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_match_allows_one_credential_to_match_multiple_namespaces() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let prf_key = [7u8; 32]; - let first_master_key = cove_cspp::master_key::MasterKey::generate(); - let second_master_key = cove_cspp::master_key::MasterKey::generate(); - let first_namespace = first_master_key.namespace_id(); - let second_namespace = second_master_key.namespace_id(); - let first_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) - .unwrap(); - let second_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) - .unwrap(); - - globals.cloud.set_master_key_backup( - first_namespace.clone(), - serde_json::to_vec(&first_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - second_namespace.clone(), - serde_json::to_vec(&second_encrypted).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let outcome = NamespacePasskeyMatcher::new( - &CloudStorage::global_explicit_client(), - PasskeyAccess::global(), - ) - .match_namespaces(&[first_namespace.clone(), second_namespace.clone()]) - .await - .unwrap(); - - let NamespaceMatchOutcome::Matched(matches) = outcome else { - panic!("expected multiple namespace matches"); - }; - let matched_namespaces = - matches.into_iter().map(|matched| matched.namespace_id).collect::>(); - - assert_eq!(matched_namespaces, vec![first_namespace, second_namespace]); -} - -#[test] -fn persist_xpub_wallets_saves_each_wallet_in_its_own_scope() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - - let first_wallet = xpub_only_wallet_metadata(); - let mut second_wallet = xpub_only_wallet_metadata(); - second_wallet.wallet_mode = WalletMode::Decoy; - - Database::global() - .wallets() - .save_all_wallets(first_wallet.network, first_wallet.wallet_mode, Vec::new()) - .unwrap(); - Database::global() - .wallets() - .save_all_wallets(second_wallet.network, second_wallet.wallet_mode, Vec::new()) - .unwrap(); - - persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); - - assert!( - Database::global() - .wallets() - .get(&first_wallet.id, first_wallet.network, first_wallet.wallet_mode) - .unwrap() - .is_some() - ); - assert!( - Database::global() - .wallets() - .get(&second_wallet.id, second_wallet.network, second_wallet.wallet_mode) - .unwrap() - .is_some() - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn wrapper_repair_discovery_propagates_unsupported_provider() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); - - let acquirer = PasskeyMaterialAcquirer::new(PasskeyAccess::global()); - let discovery_result = acquirer.discover_or_create_for_wrapper_repair().await; - let error = match discovery_result { - Ok(_) => panic!("expected unsupported passkey provider error"), - Err(error) => error, - }; - - assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_wallets_uploads_when_cloud_backup_is_enabled() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - let metadata = xpub_only_wallet_metadata(); - let xpub = sample_xpub(&metadata); - Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); - - manager.do_backup_wallets(&[metadata]).await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(4)); - assert!(Database::global().cloud_blob_sync_states.list().unwrap().into_iter().any( - |state| matches!(state.state, PersistedCloudBlobState::UploadedPendingConfirmation(_)) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_wallets_persists_partial_uploads_when_later_wallet_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - let first_wallet = xpub_only_wallet_metadata(); - let mut second_wallet = WalletMetadata::preview_new(); - second_wallet.wallet_type = WalletType::Hot; - Keychain::global() - .save_wallet_xpub(&first_wallet.id, sample_xpub(&first_wallet).parse().unwrap()) - .unwrap(); - - let error = - manager.do_backup_wallets(&[first_wallet.clone(), second_wallet]).await.unwrap_err(); - - let record_id = wallet_record_id(first_wallet.id.as_ref()); - assert!(error.to_string().contains("has no mnemonic"), "{error}"); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(4)); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_wallets_defers_local_completion_when_disable_starts_after_upload() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - let metadata = xpub_only_wallet_metadata(); - let xpub = sample_xpub(&metadata); - Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let PersistedCloudBackupState::Configured(previous_configured) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected configured cloud backup state"); - }; - globals.cloud.persist_disabling_on_next_upload(PersistedDisablingCloudBackup { - previous_configured, - namespace_id, - disable_generation: 42, - started_at: 100, - delete_started_at: None, - last_error: None, - retry_after: None, - }); - - let error = manager.do_backup_wallets(&[metadata]).await.unwrap_err(); - - assert!(matches!(error, CloudBackupError::Deferred(_))); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - Database::global().cloud_backup_state.get().unwrap(), - PersistedCloudBackupState::Disabling(_) - )); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(3)); - assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); - manager.debug_reset_cloud_backup_state(); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_new_wallet_marks_verification_required() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - manager.backup_new_wallet(xpub_only_wallet_metadata()); - - let state = Database::global().cloud_backup_state.get().unwrap(); - assert_eq!(state.status(), PersistedCloudBackupStatus::Unverified); - assert!(state.last_verification_requested_at().is_some()); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_state_changes_do_not_dismiss_verification_prompt() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - manager.backup_new_wallet(xpub_only_wallet_metadata()); - assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); - - manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Confirming); - assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); - - manager.reconcile_pending_upload_verification( - PendingUploadVerificationState::BlockedOnAuthorization, - ); - assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); - - manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - let state = manager.model_snapshot(); - assert_eq!(state.root_prompt, CloudBackupRootPrompt::Verification); - assert!(matches!( - state.verification_presentation, - CloudBackupVerificationPresentation::NeedsDecision { - reason: CloudBackupVerificationReason::BackupChanged, - .. - } - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn verification_prompt_restored_when_pending_upload_idle_hides_persisted_decision() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - manager.backup_new_wallet(xpub_only_wallet_metadata()); - manager.reconcile_verification_presentation(CloudBackupVerificationPresentation::Hidden { - source: Some(CloudBackupVerificationSource::Settings), - }); - assert_eq!(manager.state.read().snapshot().root_prompt, CloudBackupRootPrompt::None); - - manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - - let state = manager.model_snapshot(); - assert_eq!(state.root_prompt, CloudBackupRootPrompt::Verification); - assert!(matches!( - state.verification_presentation, - CloudBackupVerificationPresentation::NeedsDecision { - reason: CloudBackupVerificationReason::BackupChanged, - .. - } - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn dismiss_verification_prompt_hides_pending_decision() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - manager.backup_new_wallet(xpub_only_wallet_metadata()); - assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); - - manager.dispatch(CloudBackupManagerAction::DismissVerificationPrompt); - - let state = manager.model_snapshot(); - assert_eq!(state.root_prompt, CloudBackupRootPrompt::None); - assert!(matches!( - state.verification_presentation, - CloudBackupVerificationPresentation::Hidden { .. } - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn start_verification_with_pending_upload_consumes_prompt_decision() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - let metadata = xpub_only_wallet_metadata(); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = wallet_record_id(metadata.id.as_ref()); - manager.backup_new_wallet(metadata.clone()); - manager - .mark_blob_uploaded_pending_confirmation( - &namespace_id, - CloudBackupRecordKey::Wallet(metadata.id, record_id), - "pending-revision".into(), - crate::manager::cloud_backup_manager::current_timestamp(), - ) - .unwrap(); - assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); - - manager.dispatch(CloudBackupManagerAction::StartVerification( - CloudBackupVerificationSource::RootPrompt, - )); - - let state = manager.model_snapshot(); - assert_eq!(state.root_prompt, CloudBackupRootPrompt::None); - assert!(matches!( - state.verification_presentation, - CloudBackupVerificationPresentation::BackgroundConfirming( - CloudBackupVerificationSource::RootPrompt - ) - )); - assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_new_wallet_still_tracks_when_runtime_status_is_error() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - globals.reset(); - - let namespace = "test-namespace".to_string(); - CloudBackupKeychain::global().save_namespace_id(&namespace).unwrap(); - Database::global().cloud_blob_sync_states.delete_all().unwrap(); - manager - .persist_cloud_backup_state( - &persisted_enabled_cloud_backup_state(Some(3)), - "set cloud backup enabled for test", - ) - .unwrap(); - manager.sync_persisted_state(); - - let metadata = xpub_only_wallet_metadata(); - let record_id = wallet_record_id(metadata.id.as_ref()); - manager.reconcile_runtime_status(CloudBackupStatus::Error("offline".into())); - - manager.backup_new_wallet(metadata); - - let state = Database::global().cloud_backup_state.get().unwrap(); - assert_eq!(state.status(), PersistedCloudBackupStatus::Unverified); - assert!(state.last_verification_requested_at().is_some()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_downloaded_wallet_does_not_reupload_wallet_or_mutate_backup_counts() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 5); - - let metadata = xpub_only_wallet_metadata(); - let wallet = DownloadedWalletBackup { - metadata: metadata.clone(), - entry: WalletEntry { - wallet_id: metadata.id.to_string(), - secret: WalletSecret::WatchOnly, - metadata: serde_json::to_value(&metadata).unwrap(), - descriptors: None, - xpub: Some(sample_xpub(&metadata)), - wallet_mode: CloudWalletMode::Main, - labels_zstd_jsonl: None, - labels_count: 0, - labels_hash: None, - labels_uncompressed_size: None, - content_revision_hash: "test-content-hash".to_string(), - updated_at: 42, - }, - }; - - WalletRestoreSession::new(crate::wallet_identity::ExistingWalletIdentitySet::default()) - .restore_downloaded(&wallet) - .unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(5)); - assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); - assert!( - Database::global() - .wallets() - .get(&metadata.id, metadata.network, WalletMode::Main) - .unwrap() - .is_some() - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_downloaded_wallet_restores_labels_without_marking_cloud_backup_dirty() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 5); - - let locked_output_ref = "d97bf8892657980426c879e4ab2001f09342f1ab61cfa602741a7715a3d60290:0"; - let labels_jsonl = format!( - "{}\n{{\"type\":\"output\",\"ref\":\"{locked_output_ref}\",\"spendable\":false}}", - sample_labels_jsonl() - ); - let metadata = xpub_only_wallet_metadata(); - let wallet = DownloadedWalletBackup { - metadata: metadata.clone(), - entry: wallet_entry_with_labels(&metadata, Some(&labels_jsonl)), - }; - - let outcome = - WalletRestoreSession::new(crate::wallet_identity::ExistingWalletIdentitySet::default()) - .restore_downloaded(&wallet) - .unwrap(); - - let WalletRestoreOutcome::Restored { labels_warning } = outcome else { - panic!("expected restored wallet"); - }; - assert!(labels_warning.is_none()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(5)); - assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); - - let exported = LabelManager::new(metadata.id.clone()).export().await.unwrap(); - assert!(exported.contains("\"label\":\"last txn received\"")); - assert!(exported.contains(&format!("\"ref\":\"{locked_output_ref}\""))); - assert!(exported.contains("\"spendable\":false")); -} - -#[tokio::test(flavor = "current_thread")] -async fn cloud_action_uses_existing_master_key_without_recovery() { - cove_tokio::init(); - let store = Arc::new(MockStore::default()); - let cspp = cove_cspp::Cspp::new(MockStoreHandle(store)); - let expected = cove_cspp::master_key::MasterKey::generate(); - let namespace = expected.namespace_id(); - cspp.save_master_key(&expected).unwrap(); - - let recovered = load_master_key_for_cloud_action(&cspp, &namespace, || async { - Err(CloudBackupError::RecoveryRequired("unexpected".into())) - }) - .await - .unwrap(); - - assert_eq!(recovered.as_bytes(), expected.as_bytes()); -} - -#[tokio::test(flavor = "current_thread")] -async fn cloud_action_does_not_create_master_key_when_missing() { - cove_tokio::init(); - let store = Arc::new(MockStore::default()); - let cspp = cove_cspp::Cspp::new(MockStoreHandle(store.clone())); - let namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - - let result = load_master_key_for_cloud_action(&cspp, &namespace, || async { - Err(CloudBackupError::RecoveryRequired("needs recovery".into())) - }) - .await; - - assert!(matches!( - result, - Err(CloudBackupError::RecoveryRequired(message)) if message == "needs recovery" - )); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert_eq!(*store.save_count.lock(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn cloud_action_recovers_when_local_master_key_namespace_mismatches() { - cove_tokio::init(); - let store = Arc::new(MockStore::default()); - let cspp = cove_cspp::Cspp::new(MockStoreHandle(store)); - let stale = cove_cspp::master_key::MasterKey::generate(); - cspp.save_master_key(&stale).unwrap(); - - let expected = cove_cspp::master_key::MasterKey::generate(); - let expected_bytes = *expected.as_bytes(); - let namespace = expected.namespace_id(); - - let recovered = load_master_key_for_cloud_action(&cspp, &namespace, || async move { - Ok(cove_cspp::master_key::MasterKey::from_bytes(expected_bytes)) - }) - .await - .unwrap(); - - assert_eq!(recovered.as_bytes(), expected.as_bytes()); -} - -#[tokio::test(flavor = "current_thread")] -async fn local_master_key_fallback_persists_namespace_id() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let store = Arc::new(MockStore::default()); - let store_handle = MockStoreHandle(store.clone()); - let cspp = cove_cspp::Cspp::new(store_handle.clone()); - let expected = cove_cspp::master_key::MasterKey::generate(); - let namespace_id = expected.namespace_id(); - cspp.save_master_key(&expected).unwrap(); - globals.cloud.set_wallet_files(namespace_id.clone(), vec!["wallet-test.json".into()]); - - let (restored, restored_namespace) = restore_from_local_master_key_fallback( - &CloudStorage::global_explicit_client(), - &store_handle, - &cspp, - ) - .await - .unwrap(); - - assert_eq!(restored.as_bytes(), expected.as_bytes()); - assert_eq!(restored_namespace, namespace_id.clone()); - assert_eq!( - store_handle.get(CSPP_NAMESPACE_ID_KEY.into()).as_deref(), - Some(namespace_id.as_str()) - ); -} - -#[test] -fn save_passkey_rolls_back_on_second_save_failure() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - globals.keychain.set_entries(vec![ - (CSPP_CREDENTIAL_ID_KEY, "old_credential"), - (CSPP_PRF_SALT_KEY, "old_salt"), - ]); - globals.keychain.fail_save_at(2); - - let error = CloudBackupKeychain::global().save_passkey(&[1, 2, 3], [7; 32]).unwrap_err(); - - assert!(matches!( - error, - CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Save) - )); - assert_eq!( - globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), - Some("old_credential") - ); - assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).as_deref(), Some("old_salt")); -} - -#[test] -fn save_passkey_and_namespace_rolls_back_on_third_save_failure() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - globals.keychain.set_entries(vec![ - (CSPP_CREDENTIAL_ID_KEY, "old_credential"), - (CSPP_PRF_SALT_KEY, "old_salt"), - (CSPP_NAMESPACE_ID_KEY, "old_namespace"), - ]); - globals.keychain.fail_save_at(3); - - let error = CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3], [9; 32], "new_namespace") - .unwrap_err(); - - assert!(matches!( - error, - CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Save) - )); - assert_eq!( - globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), - Some("old_credential") - ); - assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).as_deref(), Some("old_salt")); - assert_eq!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), Some("old_namespace")); -} - -#[test] -fn load_credential_id_returns_none_for_invalid_hex_and_decodes_valid_hex() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - globals.keychain.set_entries(vec![(CSPP_CREDENTIAL_ID_KEY, "not-hex")]); - - assert!(CloudBackupKeychain::global().load_credential_id().is_none()); - - let credential_id = vec![1, 2, 3, 254, 255]; - let credential_hex = hex::encode(&credential_id); - globals.keychain.set_entries(vec![(CSPP_CREDENTIAL_ID_KEY, &credential_hex)]); - - assert_eq!(CloudBackupKeychain::global().load_credential_id(), Some(credential_id)); -} - -#[test] -fn clear_passkey_removes_credential_and_salt_only() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - globals.keychain.set_entries(vec![ - (CSPP_CREDENTIAL_ID_KEY, "credential"), - (CSPP_PRF_SALT_KEY, "salt"), - (CSPP_NAMESPACE_ID_KEY, "namespace"), - ]); - - CloudBackupKeychain::global().clear_passkey(); - - assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_none()); - assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_none()); - assert_eq!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), Some("namespace")); -} - -#[test] -fn clear_local_state_treats_empty_keychain_as_success() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - - CloudBackupKeychain::global().clear_local_state().unwrap(); - assert!(CloudBackupKeychain::global().namespace_id().is_none()); -} - -#[test] -fn clear_local_state_removes_master_key_and_passkey_metadata() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - - let keychain = Keychain::global(); - let cloud_keychain = CloudBackupKeychain::global(); - let cspp = cove_cspp::Cspp::new(keychain.clone()); - let master_key = cove_cspp::master_key::MasterKey::generate(); - cspp.save_master_key(&master_key).unwrap(); - cloud_keychain.save_passkey_and_namespace(&[1, 2, 3], [4; 32], "test-namespace").unwrap(); - - assert!(cspp.load_master_key_from_store().unwrap().is_some()); - assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_some()); - assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_some()); - assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_some()); - - cloud_keychain.clear_local_state().unwrap(); - - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); - assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); - assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); -} - -#[test] -fn clear_local_state_attempts_passkey_metadata_after_master_key_delete_failure() { - let _guard = test_lock().lock(); - let globals = test_globals(); - globals.reset(); - - let keychain = Keychain::global(); - let cloud_keychain = CloudBackupKeychain::global(); - let cspp = cove_cspp::Cspp::new(keychain.clone()); - let master_key = cove_cspp::master_key::MasterKey::generate(); - cspp.save_master_key(&master_key).unwrap(); - cloud_keychain.save_passkey_and_namespace(&[1, 2, 3], [4; 32], "test-namespace").unwrap(); - - globals.keychain.fail_delete_at(1); - - let error = cloud_keychain.clear_local_state().unwrap_err(); - - assert!(matches!( - error, - CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Delete) - )); - assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); - assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); - assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn local_master_key_fallback_is_unavailable_after_local_cloud_state_clear() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let keychain = Keychain::global(); - let cspp = cove_cspp::Cspp::new(keychain.clone()); - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace_id = master_key.namespace_id(); - cspp.save_master_key(&master_key).unwrap(); - globals.cloud.set_wallet_files(namespace_id, vec!["wallet-test.json".into()]); - - CloudBackupKeychain::global().clear_local_state().unwrap(); - - let fallback = - try_restore_from_local_master_key(&CloudStorage::global_explicit_client(), &cspp) - .await - .unwrap(); - - assert!(fallback.is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_recovery_rolls_back_local_master_key_when_wallet_upload_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - persist_xpub_wallets(vec![xpub_only_wallet_metadata()]); - globals.cloud.fail_wallet_backup_upload("upload failed"); - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - let master_key = cove_cspp::master_key::MasterKey::generate(); - let matched = NamespaceMatch { - namespace_id: master_key.namespace_id(), - master_key, - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - }; - - let preparation = manager - .prepare_enable_recovery(CloudBackupEnableContext::settings_manual(), vec![matched]) - .await - .unwrap(); - manager.save_enable_recovery_master_key(&preparation).unwrap(); - let claim = CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::Enable, 42); - manager.project_exclusive_operation_started(claim); - let writes = operation_write_client_for_test(&manager, claim); - let error = manager.prepare_enable_recovery_completion(preparation, writes).await.unwrap_err(); - manager.project_exclusive_operation_finished(claim); - manager.rollback_enable_recovery_master_key(); - - assert!(matches!(error, CloudBackupError::CloudStorage(_))); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_recovery_rolls_back_local_master_key_when_keychain_save_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - globals.keychain.fail_save_at(3); - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - let master_key = cove_cspp::master_key::MasterKey::generate(); - let matched = NamespaceMatch { - namespace_id: master_key.namespace_id(), - master_key, - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - }; - - let preparation = manager - .prepare_enable_recovery(CloudBackupEnableContext::settings_manual(), vec![matched]) - .await - .unwrap(); - manager.save_enable_recovery_master_key(&preparation).unwrap(); - let claim = CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::Enable, 42); - manager.project_exclusive_operation_started(claim); - let writes = operation_write_client_for_test(&manager, claim); - let completion = manager.prepare_enable_recovery_completion(preparation, writes).await.unwrap(); - manager.project_exclusive_operation_finished(claim); - let error = CloudBackupKeychain::global() - .save_passkey_and_namespace( - &completion.credential_id, - completion.prf_salt, - &completion.namespace_id, - ) - .unwrap_err(); - manager.rollback_enable_recovery_master_key(); - - assert!(matches!(error, CloudBackupKeychainError::Keychain(_))); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_from_local_master_key_propagates_store_read_errors() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let store = Arc::new(MockStore::default()); - let store_handle = MockStoreHandle(store.clone()); - let cspp = cove_cspp::Cspp::new(store_handle); - let expected = cove_cspp::master_key::MasterKey::generate(); - cspp.save_master_key(&expected).unwrap(); - let key_to_corrupt = - store.entries.lock().keys().next().cloned().expect("saved master key entry"); - store.entries.lock().insert(key_to_corrupt, "not-a-valid-master-key".into()); - - let error = - match try_restore_from_local_master_key(&CloudStorage::global_explicit_client(), &cspp) - .await - { - Ok(_) => panic!("expected local master key read failure"), - Err(error) => error, - }; - - assert!(matches!( - error, - CloudBackupError::Internal(message) - if message.starts_with("loading master key from store:") - )); -} - -#[test] -fn blocking_cloud_error_keeps_unavailable_storage_errors_visible() { - let error = blocking_cloud_error( - BlockingCloudStep::Enable, - CloudBackupError::CloudStorage(CloudStorageError::NotAvailable( - "iCloud Drive is not available".into(), - )), - ); - - assert!(matches!( - error, - CloudBackupError::CloudStorage(CloudStorageError::NotAvailable(message)) - if message == "iCloud Drive is not available" - )); -} - -#[test] -fn blocking_cloud_error_rewrites_offline_storage_errors_to_step_message() { - let error = blocking_cloud_error( - BlockingCloudStep::Enable, - CloudBackupError::CloudStorage(CloudStorageError::Offline("offline".into())), - ); - - assert!(matches!( - error, - CloudBackupError::Offline(message) - if message == "Reconnect to the internet, then try enabling cloud backup again" - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn failed_create_new_enable_does_not_persist_passkey_metadata() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - globals.cloud.fail_master_key_upload("boom"); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: vec![7; 32], - credential_id: vec![1, 2, 3], - })); - - let manager = init_manager(); - let error = enable_cloud_backup_create_new(&manager).await.unwrap_err(); - assert!(matches!( - error, - CloudBackupError::Internal(message) - if message.contains("upload master key backup") && message.contains("boom") - )); - - let keychain = Keychain::global(); - assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); - assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); - assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn failed_no_discovery_enable_does_not_persist_passkey_metadata() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Err(PasskeyError::RequestFailed { - operation: PasskeyOperation::AuthenticateAssertion, - reason: PasskeyFailureReason::Unknown { diagnostic_message: "boom".into() }, - })); - - let manager = init_manager(); - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - let keychain = Keychain::global(); - let cspp = cove_cspp::Cspp::new(keychain.clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_some()); - assert!(take_pending_enable_session_for_test(&manager).await.is_some()); - assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); - assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); - assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_create_new_succeeds_with_new_passkey_auth() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_create_new(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - let state = manager.model_snapshot(); - assert!(matches!(state.verification, VerificationState::Idle)); - assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); - assert!(matches!(state.root_prompt, CloudBackupRootPrompt::None)); - assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_some()); - assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_some()); - assert!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).is_some()); - - let discover_count = globals.passkey.discover_count(); - let authenticate_count = globals.passkey.authenticate_count(); - - call!(manager.supervisor.start_enter_detail()).await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - - assert_eq!(globals.passkey.discover_count(), discover_count); - assert_eq!(globals.passkey.authenticate_count(), authenticate_count); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_create_new_recovers_from_corrupted_persisted_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - manager - .persist_cloud_backup_state( - &PersistedCloudBackupState::corrupted("decode failed"), - "persist corrupt state for test", - ) - .unwrap(); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_create_new(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Unverified - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn detail_entry_starts_discoverable_verification_without_runtime_authorization() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - manager.clear_runtime_passkey_authorization(); - manager.clear_pending_verification_completion(); - manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - manager.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - Database::global().cloud_blob_sync_states.delete_all().unwrap(); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - globals.cloud.fail_list_wallet_files("list should not run before passkey auth"); - - let discover_count = globals.passkey.discover_count(); - let list_count = globals.cloud.list_wallet_files_attempt_count(); - - call!(manager.supervisor.start_enter_detail()).await.unwrap(); - wait_for_discover_count(globals, discover_count + 1).await; - - assert_eq!(globals.passkey.discover_count(), discover_count + 1); - assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_count); -} - -#[tokio::test(flavor = "current_thread")] -async fn detail_entry_does_not_restart_rust_owned_verification_states() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - let states = [ - VerificationState::Verifying, - VerificationState::Verified(DeepVerificationReport { - master_key_wrapper_repaired: false, - local_master_key_repaired: false, - credential_recovered: false, - wallets_verified: 0, - wallets_failed: 0, - wallets_unsupported: 0, - detail: None, - }), - VerificationState::PasskeyConfirmed, - ]; - - for verification in states { - configure_enabled_cloud_backup(&manager, globals, 0); - manager.clear_runtime_passkey_authorization(); - manager.clear_pending_verification_completion(); - manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - manager - .apply_verification_outcome(CloudBackupVerificationOutcome::from_state(verification)); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let discover_count = globals.passkey.discover_count(); - let authenticate_count = globals.passkey.authenticate_count(); - - call!(manager.supervisor.start_enter_detail()).await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - - assert_eq!(globals.passkey.discover_count(), discover_count); - assert_eq!(globals.passkey.authenticate_count(), authenticate_count); - } -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_authenticates_before_loading_wallet_inventory() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - configure_enabled_cloud_backup(&manager, globals, 0); - CONNECTIVITY_MANAGER.set_connection_state(true); - seed_verifiable_cloud_master_key(globals); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - globals.cloud.fail_list_wallet_files("list should not run before passkey auth"); - - let discover_count = globals.passkey.discover_count(); - let list_count = globals.cloud.list_wallet_files_attempt_count(); - - call!(manager.supervisor.start_verification(true)).await.unwrap(); - wait_for_discover_count(globals, discover_count + 1).await; - - assert_eq!(globals.passkey.discover_count(), discover_count + 1); - assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_count); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_corrupted_persisted_state_short_circuits() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - manager - .persist_cloud_backup_state( - &PersistedCloudBackupState::corrupted("decode failed"), - "persist corrupt state for test", - ) - .unwrap(); - globals.cloud.fail_list_namespaces("verify should not read cloud storage"); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let step = manager.prepare_deep_verify_cloud_backup(true).await; - - assert!(matches!( - step, - CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled) - )); - assert_eq!(globals.passkey.discover_count(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_no_discovery_succeeds_with_new_passkey_auth() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn registered_passkey_stages_confirmation_without_automatic_auth() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!(CloudBackupKeychain::global().load_credential_id(), None); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn registered_passkey_confirmation_session_prevents_duplicate_create() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); - let (_, pending_passkey) = pending.into_staged_parts().unwrap(); - assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn normal_enable_preserves_registered_passkey_confirmation_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - globals.passkey.set_create_result(Ok(vec![4, 5, 6])); - enable_cloud_backup_create_new(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); - let (_, pending_passkey) = pending.into_staged_parts().unwrap(); - assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn reinitialize_preserves_registered_passkey_confirmation_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - globals.passkey.set_create_result(Ok(vec![4, 5, 6])); - run_enable_operation( - &manager, - CloudBackupOperation::Recovery(RecoveryAction::ReinitializeBackup), - ) - .await - .unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); - let (_, pending_passkey) = pending.into_staged_parts().unwrap(); - assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn force_new_preserves_registered_passkey_confirmation_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - globals.passkey.set_create_result(Ok(vec![4, 5, 6])); - enable_cloud_backup_force_new(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); - let (_, pending_passkey) = pending.into_staged_parts().unwrap(); - assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn registered_passkey_stages_confirmation_without_duplicate_create() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); - let (_, pending_passkey) = pending.into_staged_parts().unwrap(); - assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); -} - -#[tokio::test(flavor = "current_thread")] -async fn confirm_saved_passkey_reuses_original_credential_id() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 1); - assert_eq!(globals.passkey.authenticated_credential_ids(), vec![vec![1, 2, 3]]); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn automatic_saved_passkey_confirmation_retries_until_available() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Err(PasskeyError::NoCredentialFound)); - globals.passkey.push_authenticate_result(Err(PasskeyError::NoCredentialFound)); - globals.passkey.push_authenticate_result(Ok(vec![7; 32])); - - let context = CloudBackupEnableContext { - saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, - verification_source: CloudBackupVerificationSource::Onboarding, - }; - enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.authenticate_count(), 3); - assert_eq!( - globals.passkey.authenticated_credential_ids(), - vec![vec![1, 2, 3], vec![1, 2, 3], vec![1, 2, 3]] - ); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn duplicate_confirm_saved_passkey_dispatches_are_ignored_while_confirming() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - let master_key = cove_cspp::master_key::MasterKey::generate(); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - replace_pending_enable_session_for_test( - &manager, - PendingEnableSession::awaiting_saved_passkey_confirmation( - zeroize::Zeroizing::new(master_key), - zeroize::Zeroizing::new(StagedPrfKey { - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }), - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - manager.dispatch(CloudBackupManagerAction::ConfirmSavedPasskey); - manager.dispatch(CloudBackupManagerAction::ConfirmSavedPasskey); - - wait_for_test_condition( - Duration::from_secs(1), - "saved passkey confirmation should enable cloud backup", - || manager.current_status() == CloudBackupStatus::Enabled, - ) - .await; - - assert_eq!(globals.passkey.authenticate_count(), 1); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - - let authenticate_count = globals.passkey.authenticate_count(); - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert_eq!(globals.passkey.authenticate_count(), authenticate_count); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Idle - ); - assert!(matches!(manager.model_snapshot().verification, VerificationState::Verified(_))); -} - -#[tokio::test(flavor = "current_thread")] -async fn cancelled_saved_passkey_confirmation_preserves_pending_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - globals.passkey.set_authenticate_result(Err(PasskeyError::UserCancelled)); - confirm_saved_passkey_session(&manager).await; - - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); -} - -#[tokio::test(flavor = "current_thread")] -async fn unsupported_provider_saved_passkey_confirmation_fails_without_retry() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - let master_key = cove_cspp::master_key::MasterKey::generate(); - replace_pending_enable_session_for_test( - &manager, - PendingEnableSession::awaiting_saved_passkey_confirmation( - zeroize::Zeroizing::new(master_key), - zeroize::Zeroizing::new(StagedPrfKey { - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }), - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - globals.passkey.set_authenticate_result(Err(PasskeyError::PrfUnsupportedProvider)); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(globals.passkey.authenticate_count(), 1); - assert_eq!(manager.current_status(), CloudBackupStatus::UnsupportedPasskeyProvider); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_with_multiple_matching_namespaces_merges_into_largest_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let prf_key = [7u8; 32]; - let first_master_key = cove_cspp::master_key::MasterKey::generate(); - let second_master_key = cove_cspp::master_key::MasterKey::generate(); - let first_namespace = first_master_key.namespace_id(); - let second_namespace = second_master_key.namespace_id(); - let first_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) - .unwrap(); - let second_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) - .unwrap(); - - globals.cloud.set_master_key_backup( - first_namespace.clone(), - serde_json::to_vec(&first_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - second_namespace.clone(), - serde_json::to_vec(&second_encrypted).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let first_wallet = xpub_only_wallet_metadata(); - let second_wallet = xpub_only_wallet_metadata(); - let third_wallet = xpub_only_wallet_metadata(); - let first_wallet = WalletMetadata { master_fingerprint: None, ..first_wallet }; - let second_wallet = WalletMetadata { master_fingerprint: None, ..second_wallet }; - let third_wallet = WalletMetadata { master_fingerprint: None, ..third_wallet }; - let sample_xpub_from_entropy = |metadata: &WalletMetadata, byte| { - let entropy = [byte; 16]; - let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); - - crate::mnemonic::MnemonicExt::xpub(&mnemonic, metadata.network.into()).to_string() - }; - Keychain::global() - .save_wallet_xpub( - &first_wallet.id, - sample_xpub_from_entropy(&first_wallet, 1).parse().unwrap(), - ) - .unwrap(); - Keychain::global() - .save_wallet_xpub( - &second_wallet.id, - sample_xpub_from_entropy(&second_wallet, 2).parse().unwrap(), - ) - .unwrap(); - Keychain::global() - .save_wallet_xpub( - &third_wallet.id, - sample_xpub_from_entropy(&third_wallet, 3).parse().unwrap(), - ) - .unwrap(); - - let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); - let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); - let third_record_id = cove_cspp::backup_data::wallet_record_id(third_wallet.id.as_ref()); - let first_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &first_wallet, - first_wallet.wallet_mode, - ) - .await - .unwrap() - .revision_hash; - let second_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &second_wallet, - second_wallet.wallet_mode, - ) - .await - .unwrap() - .revision_hash; - let third_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &third_wallet, - third_wallet.wallet_mode, - ) - .await - .unwrap() - .revision_hash; - globals.cloud.set_wallet_backup( - first_namespace.clone(), - first_record_id.clone(), - encrypted_wallet_backup_bytes(&first_wallet, &first_master_key, &first_revision, 1).await, - ); - globals.cloud.set_wallet_backup( - second_namespace.clone(), - second_record_id.clone(), - encrypted_wallet_backup_bytes(&second_wallet, &second_master_key, &second_revision, 1) - .await, - ); - globals.cloud.set_wallet_backup( - second_namespace.clone(), - third_record_id.clone(), - encrypted_wallet_backup_bytes(&third_wallet, &second_master_key, &third_revision, 1).await, - ); - globals.cloud.set_wallet_files( - first_namespace.clone(), - vec![wallet_filename_from_record_id(&first_record_id)], - ); - globals.cloud.set_wallet_files( - second_namespace.clone(), - vec![ - wallet_filename_from_record_id(&second_record_id), - wallet_filename_from_record_id(&third_record_id), - ], - ); - - enable_cloud_backup_create_new(&manager).await.unwrap(); - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(second_namespace.clone())); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(3)); - assert!(globals.cloud.has_namespace(&second_namespace)); - wait_for_test_condition( - Duration::from_secs(1), - "merged source namespace should be deleted after proof", - || !globals.cloud.has_namespace(&first_namespace), - ) - .await; - - let active_records = - CloudStorage::global_explicit_client().list_wallet_backups(second_namespace).await.unwrap(); - assert!(active_records.contains(&first_record_id)); - assert!(active_records.contains(&second_record_id)); - assert!(active_records.contains(&third_record_id)); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_recovery_fails_closed_when_matched_wallet_listing_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let prf_key = [7u8; 32]; - let empty_master_key = cove_cspp::master_key::MasterKey::generate(); - let wallet_master_key = cove_cspp::master_key::MasterKey::generate(); - let empty_namespace = empty_master_key.namespace_id(); - let wallet_namespace = wallet_master_key.namespace_id(); - let empty_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&empty_master_key, &prf_key, &[9; 32]) - .unwrap(); - let wallet_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&wallet_master_key, &prf_key, &[8; 32]) - .unwrap(); - - globals.cloud.set_master_key_backup( - empty_namespace.clone(), - serde_json::to_vec(&empty_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - wallet_namespace.clone(), - serde_json::to_vec(&wallet_encrypted).unwrap(), - ); - globals.cloud.fail_list_wallet_files_for_namespace( - empty_namespace.clone(), - CloudStorageError::NotFound("wallet files missing".into()), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let wallet = xpub_only_wallet_metadata(); - let wallet = WalletMetadata { master_fingerprint: None, ..wallet }; - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - let revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &wallet, - wallet.wallet_mode, - ) - .await - .unwrap() - .revision_hash; - globals.cloud.set_wallet_backup( - wallet_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &wallet_master_key, &revision, 1).await, - ); - globals.cloud.set_wallet_files( - wallet_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - - let error = enable_cloud_backup_create_new(&manager).await.unwrap_err(); - - assert!(error.to_string().contains("wallet files missing"), "{error}"); - assert_eq!(CloudBackupKeychain::global().namespace_id(), None); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); - assert!(globals.cloud.has_namespace(&empty_namespace)); - assert!(globals.cloud.has_namespace(&wallet_namespace)); -} - -struct CleanupTestSource { - namespace: String, - record_id: String, - revision_hash: Option, -} - -async fn enqueue_cleanup_for_test( - manager: &RustCloudBackupManager, - active_namespace: &str, - active_master_key: &cove_cspp::master_key::MasterKey, - source: CleanupTestSource, - wait_message: &str, - mut wait_condition: impl FnMut() -> bool, -) { - call!(manager.supervisor.enqueue_cleanup_for_test(CloudBackupCleanupJob { - cloud: CloudStorage::global_explicit_client(), - active_namespace_id: active_namespace.to_owned(), - active_critical_key: active_master_key.critical_data_key(), - sources: vec![CleanupSourceNamespace { - namespace_id: source.namespace, - expected_wallets: vec![CleanupExpectedWalletRecord { - record_id: source.record_id, - content_revision_hash: source.revision_hash, - }], - }], - })) - .await - .expect("enqueue cleanup"); - - wait_for_test_condition(Duration::from_secs(1), wait_message, &mut wait_condition).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_deletes_source_namespace_after_active_record_proof() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - active_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &active_master_key, "matching-revision", 1).await, - ); - globals.cloud.set_wallet_files( - active_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("matching-revision".into()), - }, - "cleanup should delete source namespace", - || !globals.cloud.has_namespace(&source_namespace), - ) - .await; - - assert!(!globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_keeps_source_namespace_when_active_record_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let record_id = "missing-record".to_string(); - let active_namespace_list_attempt_count = - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("expected-revision".into()), - }, - "cleanup should inspect active namespace", - || { - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) - > active_namespace_list_attempt_count - }, - ) - .await; - - assert!(globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_keeps_source_namespace_when_active_record_is_undecryptable() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let wrong_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - active_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &wrong_master_key, "expected-revision", 1).await, - ); - globals.cloud.set_wallet_files( - active_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - let active_namespace_list_attempt_count = - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("expected-revision".into()), - }, - "cleanup should inspect active namespace", - || { - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) - > active_namespace_list_attempt_count - }, - ) - .await; - - assert!(globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_keeps_source_namespace_when_active_record_is_unsupported() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - active_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &active_master_key, "expected-revision", 2).await, - ); - globals.cloud.set_wallet_files( - active_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - let active_namespace_list_attempt_count = - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("expected-revision".into()), - }, - "cleanup should inspect active namespace", - || { - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) - > active_namespace_list_attempt_count - }, - ) - .await; - - assert!(globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_keeps_source_namespace_when_active_revision_mismatches() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - active_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &active_master_key, "actual-revision", 1).await, - ); - globals.cloud.set_wallet_files( - active_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - let active_namespace_list_attempt_count = - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("expected-revision".into()), - }, - "cleanup should inspect active namespace", - || { - globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) - > active_namespace_list_attempt_count - }, - ) - .await; - - assert!(globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn cleanup_keeps_source_namespace_when_delete_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let active_master_key = cove_cspp::master_key::MasterKey::generate(); - let active_namespace = active_master_key.namespace_id(); - let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - active_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &active_master_key, "expected-revision", 1).await, - ); - globals.cloud.set_wallet_files( - active_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.set_wallet_files( - source_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.fail_delete_namespace("delete failed"); - let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); - - enqueue_cleanup_for_test( - &manager, - &active_namespace, - &active_master_key, - CleanupTestSource { - namespace: source_namespace.clone(), - record_id, - revision_hash: Some("expected-revision".into()), - }, - "cleanup should attempt source namespace delete", - || globals.cloud.delete_namespace_attempt_count() > delete_attempt_count, - ) - .await; - - assert!(globals.cloud.has_namespace(&source_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_repair_finalization_keeps_existing_count_when_wallet_refresh_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 2); - - Database::global() - .cloud_backup_state - .set(&persisted_passkey_missing_cloud_backup_state(Some(7))) - .unwrap(); - manager.sync_persisted_state(); - globals.cloud.fail_list_wallet_files("timed out"); - - let finalization = manager.prepare_passkey_repair_finalization().await.unwrap(); - manager.apply_passkey_repair_finalization(finalization).unwrap(); - - let state = Database::global().cloud_backup_state.get().unwrap(); - assert_eq!(state.status(), PersistedCloudBackupStatus::Enabled); - assert_eq!(state.wallet_count(), Some(7)); - assert_eq!(manager.model_snapshot().status, CloudBackupStatus::Enabled); -} - -#[tokio::test(flavor = "current_thread")] -async fn passkey_repair_finalization_keeps_existing_count_when_wallet_listing_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 2); - - Database::global() - .cloud_backup_state - .set(&persisted_passkey_missing_cloud_backup_state(Some(7))) - .unwrap(); - manager.sync_persisted_state(); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace, - CloudStorageError::NotFound("wallet files missing".into()), - ); - - let finalization = manager.prepare_passkey_repair_finalization().await.unwrap(); - manager.apply_passkey_repair_finalization(finalization).unwrap(); - - let state = Database::global().cloud_backup_state.get().unwrap(); - assert_eq!(state.status(), PersistedCloudBackupStatus::Enabled); - assert_eq!(state.wallet_count(), Some(7)); - assert_eq!(manager.model_snapshot().status, CloudBackupStatus::Enabled); -} - -#[tokio::test(flavor = "current_thread")] -async fn wrapper_repair_refreshes_missing_master_key_sync_health_to_uploading() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - manager.observe_sync_health(CloudSyncHealth::Failed( - SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into(), - )); - - run_repair_passkey_operation(&manager, true).await; - - for _ in 0..20 { - if manager.model_snapshot().sync_health == CloudSyncHealth::Uploading { - break; - } - - tokio::time::sleep(Duration::from_millis(10)).await; - } - - assert_eq!(manager.model_snapshot().sync_health, CloudSyncHealth::Uploading); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn wrapper_repair_fails_closed_when_wallet_listing_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace, - CloudStorageError::NotFound("wallet files missing".into()), - ); - - let error = manager.prepare_passkey_wrapper_repair_no_discovery().await.unwrap_err(); - - assert!(error.to_string().contains("wallet files missing"), "{error}"); -} - -#[tokio::test(flavor = "current_thread")] -async fn wrapper_repair_reports_failure_after_upload_when_passkey_persistence_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - globals.keychain.fail_save_at(1); - - run_repair_passkey_operation(&manager, true).await; - - assert!(globals.cloud.has_master_key_backup(&namespace)); - assert_eq!(CloudBackupKeychain::global().load_credential_id(), None); - assert_eq!(CloudBackupKeychain::global().load_prf_salt(), None); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_deletes_active_namespace_and_clears_local_cloud_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::master_key_wrapper( - namespace.clone(), - PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), - )) - .unwrap(); - - run_disable_cloud_backup(&manager).await; - - assert!(!globals.cloud.has_namespace(&namespace)); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); - assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); - assert!(CloudBackupKeychain::global().namespace_id().is_none()); - assert!( - globals.cloud.deleted_namespace_policies().contains(&CloudAccessPolicy::ConsentAllowed) - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_keeps_disabling_state_when_local_cleanup_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.keychain.fail_delete_at(1); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - - assert!(error.contains("clear cloud backup local keychain state"), "{error}"); - assert!(!globals.cloud.has_namespace(&namespace)); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_some()); - assert!( - disabling - .last_error - .as_deref() - .is_some_and(|message| message.contains("clear cloud backup local keychain state")) - ); - assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); - - run_disable_cloud_backup(&manager).await; - - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); - assert!(current_disable_generation().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_blocks_cloud_only_wallets_without_deleting_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - - assert!(error.contains("cloud-only wallets"), "{error}"); - assert!(globals.cloud.has_namespace(&namespace)); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_none()); - assert!(current_disable_generation().is_some()); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_uses_unique_generation_for_each_attempt() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); - - run_disable_cloud_backup(&manager).await; - let first_generation = current_disable_generation().unwrap(); - run_keep_cloud_backup_enabled(&manager).await; - - run_disable_cloud_backup(&manager).await; - let second_generation = current_disable_generation().unwrap(); - - assert_ne!(first_generation, second_generation); -} - -#[tokio::test(flavor = "current_thread")] -async fn keep_cloud_backup_enabled_clears_rolled_back_disable_failure() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); - - run_disable_cloud_backup(&manager).await; - - assert!(globals.cloud.has_namespace(&namespace)); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_none()); - assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); - let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { - panic!("expected configured cloud backup lifecycle"); - }; - assert!(matches!( - configured.destructive_operation, - CloudBackupDestructiveOperationState::DisableFailed { can_keep_enabled: true, .. } - )); - - run_keep_cloud_backup_enabled(&manager).await; - - let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { - panic!("expected configured cloud backup lifecycle"); - }; - assert_eq!(configured.destructive_operation, CloudBackupDestructiveOperationState::Idle); - assert!(globals.cloud.has_namespace(&namespace)); - assert!(current_disable_generation().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn keep_cloud_backup_enabled_resumes_dirty_wallet_marked_during_disable_fence() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = wallet_record_id(metadata.id.as_ref()); - let PersistedCloudBackupState::Configured(previous_configured) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected configured cloud backup state"); - }; - Database::global() - .cloud_backup_state - .set(&PersistedCloudBackupState::Disabling(PersistedDisablingCloudBackup { - previous_configured, - namespace_id: namespace.clone(), - disable_generation: 42, - started_at: 100, - delete_started_at: None, - last_error: None, - retry_after: None, - })) - .unwrap(); - - manager.handle_wallet_backup_change(metadata.id.clone()); - - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 0); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); - - run_keep_cloud_backup_enabled(&manager).await; - - wait_for_test_condition( - Duration::from_secs(5), - "dirty wallet marked during disable fence uploads after Keep Enabled", - || { - let upload_attempted = globals.cloud.wallet_backup_upload_attempt_count() > 0; - let uploaded_or_confirmed = matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - ); - - upload_attempted && uploaded_or_confirmed - }, - ) - .await; - assert!(matches!( - Database::global().cloud_backup_state.get().unwrap(), - PersistedCloudBackupState::Configured(_) - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn keep_cloud_backup_enabled_preserves_configured_runtime_status() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 3); - - Database::global() - .cloud_backup_state - .set(&persisted_passkey_missing_cloud_backup_state(Some(3))) - .unwrap(); - manager.reconcile_runtime_status(CloudBackupStatus::Enabled); - manager.apply_disable_outcome(CloudBackupDisableOutcome::Failed { - message: "disable failed".into(), - can_keep_enabled: true, - }); - - manager.finish_keep_cloud_backup_enabled(); - - assert_eq!(manager.current_status(), CloudBackupStatus::PasskeyMissing); - let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { - panic!("expected configured cloud backup lifecycle"); - }; - assert_eq!(configured.destructive_operation, CloudBackupDestructiveOperationState::Idle); -} - -#[tokio::test(flavor = "current_thread")] -async fn keep_cloud_backup_enabled_ignores_stale_disabling_generation() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let PersistedCloudBackupState::Configured(previous_configured) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected configured state"); - }; - - let stale = PersistedDisablingCloudBackup { - previous_configured: previous_configured.clone(), - namespace_id: namespace_id.clone(), - disable_generation: 10, - started_at: 100, - delete_started_at: None, - last_error: None, - retry_after: None, - }; - let current = PersistedDisablingCloudBackup { disable_generation: 11, ..stale.clone() }; - Database::global() - .cloud_backup_state - .set(&PersistedCloudBackupState::Disabling(current)) - .unwrap(); - - let restored = manager.restore_configured_cloud_backup_after_disable(&stale).unwrap(); - - assert!(!restored); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected disabling state"); - }; - assert_eq!(disabling.disable_generation, 11); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_blocks_other_namespaces_without_deleting_them() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let other_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.set_master_key_backup(other_namespace.clone(), vec![4, 5, 6]); - let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - - assert!(error.contains("other cloud backups"), "{error}"); - assert!(globals.cloud.has_namespace(&namespace)); - assert!(globals.cloud.has_namespace(&other_namespace)); - assert_eq!(globals.cloud.delete_namespace_attempt_count(), delete_attempt_count); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_blocks_active_exclusive_operation_without_persisting_disabling() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - let claim = - CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::RecreateManifest, 1); - manager.project_exclusive_operation_started(claim); - - let error = manager.prepare_disable_cloud_backup().await.unwrap_err(); - - assert!(error.to_string().contains("another cloud backup operation"), "{error}"); - assert!(globals.cloud.has_namespace(&namespace)); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!( - manager.projected_exclusive_operation().map(|claim| claim.operation()), - Some(CloudBackupExclusiveOperation::RecreateManifest) - ); - manager.project_exclusive_operation_finished(claim); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_corrupted_persisted_state_fails_closed() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - manager - .persist_cloud_backup_state( - &PersistedCloudBackupState::corrupted("decode failed"), - "persist corrupt state for test", - ) - .unwrap(); - globals.cloud.fail_list_wallet_files("disable should not inspect cloud wallets"); - let list_wallet_files_attempt_count = globals.cloud.list_wallet_files_attempt_count(); - - let error = manager.prepare_disable_cloud_backup().await.unwrap_err(); - - assert!(matches!( - error, - CloudBackupError::Internal(message) if message == CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE - )); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Corrupted - ); - assert!(matches!( - manager.current_status(), - CloudBackupStatus::Error(message) if message == CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE - )); - assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_wallet_files_attempt_count); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_delete_failure_keeps_disabling_state_and_keychain() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_delete_namespace("delete failed"); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - - assert!(error.contains("delete failed"), "{error}"); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_some()); - assert!( - disabling.last_error.as_deref().is_some_and(|message| message.contains("delete failed")) - ); - assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); - assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_not_found_listing_retries_then_fails_closed() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace.clone(), - CloudStorageError::NotFound("missing".into()), - ); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - - assert!(error.contains("list wallet backups"), "{error}"); - assert_eq!(globals.cloud.list_wallet_files_attempt_count_for_namespace(&namespace), 4); - assert_eq!(globals.cloud.delete_namespace_attempt_count(), 0); - assert!(globals.cloud.has_namespace(&namespace)); - assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); - let PersistedCloudBackupState::Disabling(disabling) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected persisted disabling state"); - }; - assert!(disabling.delete_started_at.is_none()); - assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); -} - -#[tokio::test(flavor = "current_thread")] -async fn disable_cloud_backup_delete_not_found_finishes_cleanup() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_delete_namespace_not_found("already deleted"); - - run_disable_cloud_backup(&manager).await; - - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); - assert!(CloudBackupKeychain::global().namespace_id().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn persisted_disabling_on_restart_resumes_to_disabled() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - let PersistedCloudBackupState::Configured(previous_configured) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected configured cloud backup state"); - }; - - Database::global() - .cloud_backup_state - .set(&PersistedCloudBackupState::Disabling(PersistedDisablingCloudBackup { - previous_configured, - namespace_id: namespace.clone(), - disable_generation: 42, - started_at: 41, - delete_started_at: Some(43), - last_error: Some("interrupted".into()), - retry_after: Some(44), - })) - .unwrap(); - - let restarted_manager = init_manager(); - wait_for_test_condition(Duration::from_secs(2), "restart disable recovery finishes", || { - !globals.cloud.has_namespace(&namespace) - && Database::global().cloud_backup_state.get().unwrap().status() - == PersistedCloudBackupStatus::Disabled - && CloudBackupKeychain::global().namespace_id().is_none() - && restarted_manager.model_snapshot().status == CloudBackupStatus::Disabled - }) - .await; - - assert!(!globals.cloud.has_namespace(&namespace)); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); - assert!(CloudBackupKeychain::global().namespace_id().is_none()); - assert_eq!(restarted_manager.model_snapshot().status, CloudBackupStatus::Disabled); -} - -#[tokio::test(flavor = "current_thread")] -async fn keep_cloud_backup_enabled_after_delete_failure_requires_existing_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_delete_namespace("delete failed"); - - run_disable_cloud_backup(&manager).await; - let error = disable_failure_message(&manager); - assert!(error.contains("delete failed"), "{error}"); - - run_keep_cloud_backup_enabled(&manager).await; - - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); - assert!(current_disable_generation().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn reupload_all_wallets_does_not_create_master_key_for_existing_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - CloudBackupKeychain::global().save_namespace_id("0123456789abcdef0123456789abcdef").unwrap(); - - let manager = init_manager(); - let claim = - CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::RecreateManifest, 1); - let writes = operation_write_client_for_test(&manager, claim); - let error = manager.prepare_reupload_all_wallets(writes).await.unwrap_err(); - - assert!(matches!( - error, - CloudBackupError::RecoveryRequired(message) - if message == RECREATE_MANIFEST_RECOVERY_MESSAGE - )); - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn reupload_all_wallets_persists_full_cloud_wallet_count() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - globals - .cloud - .set_wallet_files(namespace, vec![wallet_filename_from_record_id("cloud-only-record")]); - - run_recreate_manifest(&manager).await; - - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(2)); - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn fetch_cloud_only_wallets_surfaces_unsupported_versions() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let keychain = Keychain::global(); - let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); - let master_key = - cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let wallets = manager.do_fetch_cloud_only_wallets().await.unwrap(); - - assert_eq!(wallets.len(), 1); - assert_eq!(wallets[0].record_id, record_id); - assert_eq!(wallets[0].name, UNSUPPORTED_CLOUD_ONLY_WALLET_NAME); - assert_eq!(wallets[0].sync_status, CloudBackupWalletStatus::UnsupportedVersion); - assert_eq!(wallets[0].network, None); - assert_eq!(wallets[0].wallet_mode, None); - assert_eq!(wallets[0].wallet_type, None); - assert_eq!(wallets[0].label_count, None); - assert_eq!(wallets[0].backup_updated_at, None); -} - -#[tokio::test(flavor = "current_thread")] -async fn detail_reports_other_backup_namespaces() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); - globals.cloud.set_wallet_files( - current_namespace, - vec![wallet_filename_from_record_id("current-wallet")], - ); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = cove_cspp::master_key_crypto::encrypt_master_key_with_provider_hint( - &other_master_key, - &[7; 32], - &[9; 32], - Some(cove_cspp::backup_data::PasskeyProviderHint { - aaguid: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4".into(), - registered_platform: cove_cspp::backup_data::PasskeyRegistrationPlatform::Android, - registered_at: 1_777_661_234, - name_suffix: "09IX".into(), - }), - ) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.cloud.set_wallet_files( - other_namespace, - vec![ - wallet_filename_from_record_id("other-wallet-1"), - wallet_filename_from_record_id("other-wallet-2"), - ], - ); - - let Some(CloudBackupDetailResult::Success(detail)) = - manager.refresh_cloud_backup_detail().await - else { - panic!("expected cloud backup detail"); - }; - - let CloudBackupOtherBackupsState::Loaded { summary } = detail.other_backups else { - panic!("expected loaded other backups"); - }; - assert_eq!(summary.namespace_count, 1); - assert_eq!(summary.wallet_count, 2); - assert_eq!(summary.passkey_hints.len(), 1); - assert_eq!(summary.passkey_hints[0].name_suffix, "09IX"); - assert_eq!(summary.passkey_hints[0].provider_name.as_deref(), Some("Google Password Manager")); -} - -#[tokio::test(flavor = "current_thread")] -async fn other_backup_summary_counts_only_wallets_missing_from_local_wallets() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let local_record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata]); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_wallet_files(current_namespace.clone(), Vec::new()); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); - globals.cloud.set_wallet_files( - other_namespace, - vec![ - wallet_filename_from_record_id(&local_record_id), - wallet_filename_from_record_id("missing-local-wallet"), - ], - ); - - let summary = - manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); - assert_eq!(summary.namespace_count, 1); - assert_eq!(summary.wallet_count, 1); - - globals.cloud.set_wallet_files( - current_namespace, - vec![wallet_filename_from_record_id("missing-local-wallet")], - ); - - let summary = - manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); - assert_eq!(summary.namespace_count, 1); - assert_eq!(summary.wallet_count, 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn other_backup_summary_fails_closed_when_wallet_listing_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_list_wallet_files_for_namespace( - other_namespace, - CloudStorageError::NotFound("wallet files missing".into()), - ); - - let error = - manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap_err(); - - assert!(error.to_string().contains("wallet files missing"), "{error}"); -} - -#[tokio::test(flavor = "current_thread")] -async fn detail_refresh_marks_other_backups_failed_when_namespace_inspection_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_wallet_files(current_namespace, Vec::new()); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); - globals - .cloud - .fail_master_key_download_offline(other_namespace, "offline while inspecting namespace"); - - let Some(CloudBackupDetailResult::Success(detail)) = - manager.refresh_cloud_backup_detail().await - else { - panic!("expected cloud backup detail"); - }; - - let CloudBackupOtherBackupsState::LoadFailed { error } = detail.other_backups else { - panic!("expected failed other backups state"); - }; - assert_eq!( - error, - "offline: Reconnect to the internet, then try refreshing cloud backup details again" - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_keeps_current_passkey_metadata() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[9, 8, 7], [6; 32], ¤t_namespace) - .unwrap(); - - let prf_key = [7u8; 32]; - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - other_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, - ); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - - let current_namespace_list_attempt_count = - globals.cloud.list_wallet_files_attempt_count_for_namespace(¤t_namespace); - let report = manager.do_recover_other_backups().await.unwrap(); - - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!( - globals.cloud.list_wallet_files_attempt_count_for_namespace(¤t_namespace), - current_namespace_list_attempt_count + 3, - ); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(!globals.cloud.has_namespace(&other_namespace)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(current_namespace.clone())); - assert_eq!( - globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), - Some(current_namespace.as_str()) - ); - assert_eq!(CloudBackupKeychain::global().load_credential_id(), Some(vec![9, 8, 7])); - assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_some()); - - let summary = - manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); - assert_eq!(summary.namespace_count, 0); - assert_eq!(summary.wallet_count, 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_current_namespace_not_found_fails_closed_without_source_delete() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.fail_list_wallet_files_for_namespace( - current_namespace, - CloudStorageError::NotFound("current wallet files missing".into()), - ); - - let prf_key = [7u8; 32]; - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - other_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, - ); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); - - let error = manager.do_recover_other_backups().await.unwrap_err(); - - assert!(error.to_string().contains("current wallet files missing"), "{error}"); - assert!(globals.cloud.has_namespace(&other_namespace)); - assert_eq!(globals.cloud.delete_namespace_attempt_count(), delete_attempt_count); -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_keeps_partially_moved_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let prf_key = [7u8; 32]; - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let restored_wallet = xpub_only_wallet_metadata(); - Keychain::global() - .save_wallet_xpub(&restored_wallet.id, sample_xpub(&restored_wallet).parse().unwrap()) - .unwrap(); - let restored_record_id = cove_cspp::backup_data::wallet_record_id(restored_wallet.id.as_ref()); - let missing_wallet = xpub_only_wallet_metadata(); - let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); - - globals.cloud.set_wallet_backup( - other_namespace.clone(), - restored_record_id.clone(), - encrypted_wallet_backup_bytes(&restored_wallet, &other_master_key, "other-revision", 1) - .await, - ); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![ - wallet_filename_from_record_id(&restored_record_id), - wallet_filename_from_record_id(&missing_record_id), - ], - ); - - let report = manager.do_recover_other_backups().await.unwrap(); - - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 1); - assert!(globals.cloud.has_namespace(&other_namespace)); - - let summary = - manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); - assert_eq!(summary.namespace_count, 1); - assert_eq!(summary.wallet_count, 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_keeps_namespace_when_current_upload_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - globals.cloud.fail_wallet_backup_upload("upload failed"); - - let prf_key = [7u8; 32]; - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - other_namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, - ); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - - let result = manager.do_recover_other_backups().await; - - assert!(result.is_err()); - assert!(globals.cloud.has_namespace(&other_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_returns_offline_when_wallet_download_is_offline() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let prf_key = [7u8; 32]; - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let wallet = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![wallet_filename_from_record_id(&record_id)], - ); - globals.cloud.fail_wallet_backup_download_offline( - other_namespace, - record_id, - "offline while downloading wallet", - ); - - let result = manager.do_recover_other_backups().await; - - match result { - Err(CloudBackupError::Offline(message)) => { - assert_eq!( - message, - "Reconnect to the internet, then try recovering the other cloud backups again" - ); - } - Ok(report) => panic!( - "expected offline error, got report with {} failed wallet(s)", - report.wallets_failed - ), - Err(error) => panic!("expected offline error, got {error:?}"), - } -} - -#[tokio::test(flavor = "current_thread")] -async fn recover_other_backups_returns_offline_when_namespace_inspection_is_offline() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals - .cloud - .fail_master_key_download_offline(other_namespace, "offline while inspecting namespace"); - - let result = manager.do_recover_other_backups().await; - - match result { - Err(CloudBackupError::Offline(message)) => { - assert_eq!( - message, - "Reconnect to the internet, then try recovering the other cloud backups again" - ); - } - Ok(report) => panic!( - "expected offline error, got report with {} restored wallet(s)", - report.wallets_restored - ), - Err(error) => panic!("expected offline error, got {error:?}"), - } -} - -#[tokio::test(flavor = "current_thread")] -async fn delete_other_backups_removes_only_non_current_namespaces() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); - globals.cloud.set_wallet_files( - current_namespace.clone(), - vec![wallet_filename_from_record_id("current-wallet")], - ); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.cloud.set_wallet_files( - other_namespace.clone(), - vec![wallet_filename_from_record_id("other-wallet")], - ); - - manager.do_delete_other_backups().await.unwrap(); - - assert!(globals.cloud.has_namespace(¤t_namespace)); - assert!(!globals.cloud.has_namespace(&other_namespace)); - assert_eq!(globals.cloud.deleted_namespace_policies(), vec![CloudAccessPolicy::ConsentAllowed]); -} - -#[tokio::test(flavor = "current_thread")] -async fn delete_other_backups_returns_offline_when_namespace_inspection_is_offline() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); - - let other_master_key = cove_cspp::master_key::MasterKey::generate(); - let other_namespace = other_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - other_namespace.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.cloud.fail_master_key_download_offline( - other_namespace.clone(), - "offline while inspecting namespace", - ); - - let result = manager.do_delete_other_backups().await; - - match result { - Err(CloudBackupError::Offline(message)) => { - assert_eq!( - message, - "Reconnect to the internet, then try deleting the other cloud backups again" - ); - } - Ok(()) => panic!("expected offline error"), - Err(error) => panic!("expected offline error, got {error:?}"), - } - assert!(globals.cloud.has_namespace(&other_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_wallets_does_not_create_master_key_or_upload_when_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let namespace = "0123456789abcdef0123456789abcdef"; - CloudBackupKeychain::global().save_namespace_id(namespace).unwrap(); - - let manager = init_manager(); - let mut metadata = WalletMetadata::preview_new(); - metadata.wallet_type = crate::wallet::metadata::WalletType::WatchOnly; - - let error = manager.do_backup_wallets(&[metadata]).await.unwrap_err(); - - match error { - CloudBackupError::RecoveryRequired(message) => { - assert_eq!(message, "Cloud backup needs verification before wallets can be uploaded"); - } - error => panic!("expected recovery-required upload error, got {error:?}"), - } - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_does_not_create_master_key_for_existing_namespace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - globals.reset(); - - let namespace = "0123456789abcdef0123456789abcdef"; - CloudBackupKeychain::global().save_namespace_id(namespace).unwrap(); - - let manager = init_manager(); - let metadata = xpub_only_wallet_metadata(); - let xpub = sample_xpub(&metadata); - Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); - Database::global() - .wallets() - .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) - .unwrap(); - - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace.into(), - metadata.id.clone(), - record_id.clone(), - PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), - )) - .unwrap(); - - let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); - - match error { - CloudBackupError::RecoveryRequired(message) => { - assert_eq!(message, "Cloud backup needs verification before wallets can be uploaded"); - } - error => panic!("expected recovery-required upload error, got {error:?}"), - } - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn deferred_live_wallet_upload_retries_without_restart() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - persist_dirty_blob_state(metadata.id.clone()); - globals.cloud.fail_next_wallet_backup_upload_offline("offline"); - - run_wallet_upload_for_test_async(&manager, metadata.id.clone()).await; - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); - - wait_for_test_condition( - Duration::from_secs(7), - "deferred live upload should retry automatically after the backoff", - || globals.cloud.wallet_backup_upload_attempt_count() >= 2, - ) - .await; - - wait_for_test_condition( - Duration::from_secs(1), - "deferred live upload should eventually reach an uploaded state", - || { - matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - ) - }, - ) - .await; - assert!(globals.cloud.uploaded_wallet_backup_count() >= 1); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn failed_blob_states_recover_only_after_last_failed_wallet_upload_recovers() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let first_wallet = xpub_only_wallet_metadata(); - let second_wallet = xpub_only_wallet_metadata(); - let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); - let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); - - persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); - persist_dirty_blob_state(first_wallet.id.clone()); - persist_dirty_blob_state(second_wallet.id.clone()); - globals.cloud.fail_wallet_backup_upload("upload failed"); - - run_wallet_upload_for_test_async(&manager, first_wallet.id.clone()).await; - run_wallet_upload_for_test_async(&manager, second_wallet.id.clone()).await; - - assert!(matches!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(message) if message.contains("upload failed") - )); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&first_record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) - )); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) - )); - - globals.cloud.clear_wallet_backup_upload_failure(); - - run_wallet_upload_for_test_async(&manager, first_wallet.id.clone()).await; - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(message) if message.contains("upload failed") - )); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&first_record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) - )); - - run_wallet_upload_for_test_async(&manager, second_wallet.id.clone()).await; - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 2); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); - assert!( - Database::global() - .cloud_blob_sync_states - .list() - .unwrap() - .into_iter() - .all(|state| !matches!(state.state, PersistedCloudBlobState::Failed(_))) - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn corrupt_blob_sync_state_projects_failed_sync_health() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::corrupted( - "failed to decode persisted cloud backup blob sync state".into(), - )) - .unwrap(); - - assert!(matches!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(message) - if message.contains("failed to decode persisted cloud backup blob sync state") - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn connectivity_reconnect_preserves_failed_wallet_upload_health() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - CloudBackupKeychain::global().namespace_id().unwrap(), - metadata.id, - record_id, - PersistedCloudBlobState::Failed(CloudBlobFailedState { - revision_hash: None, - error: "upload failed".into(), - retryable: false, - issue: None, - failed_at: 1, - }), - )) - .unwrap(); - - manager.handle_connectivity_change(ConnectivityStatus::Connected); - - assert!(matches!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(message) if message.contains("upload failed") - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn connectivity_reconnect_reports_clean_health_when_failed_wallet_uploads_are_gone() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - manager.handle_connectivity_change(ConnectivityStatus::Connected); - - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::NoFiles); -} - -#[tokio::test(flavor = "current_thread")] -async fn reconnect_retries_verification_after_offline_failure() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - seed_verifiable_cloud_master_key(globals); - CONNECTIVITY_MANAGER.set_connection_state(false); - - call!(manager.supervisor.start_verification(false)).await.unwrap(); - wait_for_test_condition( - Duration::from_secs(1), - "expected offline verification failure", - || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), - ) - .await; - - CONNECTIVITY_MANAGER.set_connection_state(true); - - wait_for_test_condition( - Duration::from_secs(1), - "expected reconnect to retry and verify backup", - || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), - ) - .await; -} - -#[tokio::test(flavor = "current_thread")] -async fn connected_connectivity_failure_retries_detail_refresh_once() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.cloud.fail_next_list_wallet_files_offline("offline"); - - call!(manager.supervisor.start_refresh_detail()).await.unwrap(); - - wait_for_test_condition( - Duration::from_secs(1), - "expected connectivity retry to refresh detail", - || manager.model_snapshot().detail.is_some(), - ) - .await; -} - -#[tokio::test(flavor = "current_thread")] -async fn connected_connectivity_failure_retries_verification_once() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - CONNECTIVITY_MANAGER.set_connection_state(true); - seed_verifiable_cloud_master_key(globals); - globals.cloud.fail_next_list_wallet_files_offline("offline"); - - call!(manager.supervisor.start_verification(false)).await.unwrap(); - - wait_for_test_condition( - Duration::from_secs(1), - "expected connected offline failure to retry verification", - || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), - ) - .await; -} - -#[tokio::test(flavor = "current_thread")] -async fn unknown_connectivity_does_not_block_verification() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - seed_verifiable_cloud_master_key(globals); - CONNECTIVITY_MANAGER.set_connection_status(ConnectivityStatus::Unknown); - - call!(manager.supervisor.start_verification(false)).await.unwrap(); - - wait_for_test_condition( - Duration::from_secs(1), - "expected unknown connectivity to attempt verification", - || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), - ) - .await; -} - -#[tokio::test(flavor = "current_thread")] -async fn non_connectivity_verification_failure_does_not_retry_on_reconnect() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.cloud.fail_list_wallet_files("list failed"); - - call!(manager.supervisor.start_verification(false)).await.unwrap(); - wait_for_test_condition( - Duration::from_secs(1), - "expected non-connectivity verification failure", - || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), - ) - .await; - - CONNECTIVITY_MANAGER.set_connection_state(false); - manager.handle_connectivity_change(ConnectivityStatus::Disconnected); - CONNECTIVITY_MANAGER.set_connection_state(true); - manager.handle_connectivity_change(ConnectivityStatus::Connected); - - assert_test_condition_stays_true( - Duration::from_millis(150), - "non-connectivity failure should stay failed after reconnect", - || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), - ) - .await; -} - -#[test] -fn reset_cloud_backup_test_state_clears_state_before_reconnect() { - let _guard = test_lock().lock(); - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let wallet_id = xpub_only_wallet_metadata().id; - let record_id = cove_cspp::backup_data::wallet_record_id(wallet_id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - CloudBackupKeychain::global().namespace_id().unwrap(), - wallet_id, - record_id, - PersistedCloudBlobState::Failed(CloudBlobFailedState { - revision_hash: None, - error: "upload failed".into(), - retryable: false, - issue: None, - failed_at: 1, - }), - )) - .unwrap(); - manager.apply_enable_outcome(CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - )); - CONNECTIVITY_MANAGER.set_connection_state(false); - - reset_cloud_backup_test_state_with_hook(&manager, globals, || { - assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); - assert_eq!(manager.model_snapshot().enable_state, CloudBackupEnableState::Idle); - }); - - assert!(CONNECTIVITY_MANAGER.is_connected()); -} - -#[tokio::test(flavor = "current_thread")] -async fn startup_resume_skips_non_retryable_failed_wallet_uploads() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - persist_failed_blob_state(metadata.id.clone(), false); - globals.cloud.fail_wallet_backup_upload_quota_exceeded(); - let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); - - resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; - - assert_test_condition_stays_true( - Duration::from_millis(250), - "startup resume should not retry non-retryable failed uploads", - || globals.cloud.wallet_backup_upload_attempt_count() == initial_attempt_count, - ) - .await; - - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), initial_attempt_count); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), - .. - }) - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; - globals.cloud.clear_wallet_backup_upload_failure(); -} - -#[tokio::test(flavor = "current_thread")] -async fn startup_resume_retries_authorization_failed_wallet_uploads() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - persist_failed_blob_state_with_issue( - metadata.id, - false, - Some(CloudBlobFailureIssue::AuthorizationRequired), - ); - let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); - - resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; - - assert_eq!( - manager.compute_sync_health().await, - CloudSyncHealth::AuthorizationRequired("failed".into()), - ); - wait_for_test_condition( - Duration::from_secs(1), - "startup resume should retry authorization failures", - || globals.cloud.wallet_backup_upload_attempt_count() > initial_attempt_count, - ) - .await; - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn cloud_storage_change_retries_authorization_failed_wallet_uploads() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - persist_failed_blob_state_with_issue( - metadata.id, - false, - Some(CloudBlobFailureIssue::AuthorizationRequired), - ); - let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); - - manager.cloud_storage_did_change(); - - wait_for_test_condition( - Duration::from_secs(3), - "cloud storage change should retry authorization failures", - || globals.cloud.wallet_backup_upload_attempt_count() > initial_attempt_count, - ) - .await; - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_reports_authorization_required_for_persisted_auth_failures() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - persist_failed_blob_state_with_issue( - metadata.id, - true, - Some(CloudBlobFailureIssue::AuthorizationRequired), - ); - - assert_eq!( - manager.compute_sync_health().await, - CloudSyncHealth::AuthorizationRequired("failed".into()), - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_reports_uploading_for_fresh_pending_master_key_confirmation() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let master_json = vec![1, 2, 3]; - let master_revision = master_key_wrapper_revision_hash(&master_json); - persist_pending_master_key_confirmation(namespace_id.clone(), master_revision); - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading); - - globals.cloud.set_master_key_backup(namespace_id, master_json); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::AllUploaded); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Idle - ); - assert!(matches!(manager.model_snapshot().verification, VerificationState::Idle)); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_blocks_on_cloud_authorization() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - persist_pending_master_key_confirmation(namespace_id.clone(), "pending"); - globals - .cloud - .fail_master_key_download_authorization_required(namespace_id, "authorization required"); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(has_more_pending); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::BlockedOnAuthorization - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_reports_uploading_for_fresh_pending_master_key_with_local_wallets() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - persist_pending_master_key_confirmation(namespace_id, "pending"); - - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn force_new_reports_uploading_while_master_key_confirmation_is_pending() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - let existing_namespace = "fedcba9876543210fedcba9876543210"; - globals.cloud.set_master_key_backup(existing_namespace.into(), vec![1, 2, 3]); - globals.cloud.set_wallet_files(existing_namespace.into(), vec!["wallet-1.json".into()]); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - enable_cloud_backup_force_new(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudStorage::global_silent_client() - .delete_wallet_backup(namespace_id.clone(), cspp_master_key_record_id()) - .await - .unwrap(); - persist_pending_master_key_confirmation(namespace_id, "pending"); - - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_reports_missing_master_key_without_pending_confirmation() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - - assert_eq!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into()), - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_reports_missing_master_key_before_pending_wallet_uploads() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - persist_dirty_blob_state(metadata.id); - - assert_eq!( - manager.compute_sync_health().await, - CloudSyncHealth::Failed(SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into()), - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_health_respects_master_key_upload_confirmation_grace() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - - assert_eq!( - manager.compute_sync_health_with_master_key_grace(Some(&namespace_id)).await, - CloudSyncHealth::Uploading, - ); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn startup_resume_retries_interrupted_uploading_wallets() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - clear_wallet_upload_runtime_for_test_async(&manager).await; - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - persist_uploading_blob_state(metadata.id, 1); - - resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; - - wait_for_test_condition( - Duration::from_secs(5), - "startup resume should retry interrupted uploads", - || { - let upload_state_is_pending_or_confirmed = matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - ); - - globals.cloud.wallet_backup_upload_attempt_count() >= 1 - && upload_state_is_pending_or_confirmed - }, - ) - .await; - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn validate_metadata_marks_generated_wallet_names_dirty() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = global_manager(); - reset_cloud_backup_test_state(&manager, globals); - - let mut metadata = WalletMetadata::preview_new(); - metadata.name.clear(); - - let wallet = Wallet::try_new_persisted_and_selected( - metadata, - Mnemonic::parse( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - ) - .unwrap(), - None, - ) - .unwrap(); - let wallet_id = wallet.id(); - let stored_metadata = Database::global() - .wallets() - .get(&wallet_id, wallet.network, wallet.metadata.wallet_mode) - .unwrap() - .unwrap(); - let expected_name = stored_metadata - .master_fingerprint - .as_deref() - .map_or_else(|| "Unnamed Wallet".to_string(), |fingerprint| fingerprint.as_uppercase()); - - enable_cloud_backup_without_reset(&manager, 1); - - let wallet_manager = RustWalletManager::try_new(wallet_id.clone()).unwrap(); - wallet_manager.validate_metadata(); - - let updated_metadata = Database::global() - .wallets() - .get(&wallet_id, wallet.network, wallet.metadata.wallet_mode) - .unwrap() - .unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet_id.as_ref()); - - assert_eq!(updated_metadata.name, expected_name); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); - - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_removes_deleted_wallet_sync_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = WalletMetadata::preview_new(); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace.clone(), - metadata.id.clone(), - record_id.clone(), - PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), - )) - .unwrap(); - - manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); - - assert!(Database::global().cloud_blob_sync_states.get(&record_id).unwrap().is_none()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_and_integrity_skip_pending_upload_candidates() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let mut metadata = WalletMetadata::preview_new(); - metadata.wallet_type = WalletType::WatchOnly; - Database::global() - .wallets() - .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) - .unwrap(); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace_id, - metadata.id.clone(), - record_id, - PersistedCloudBlobState::UploadedPendingConfirmation( - CloudBlobUploadedPendingConfirmationState { - revision_hash: "rev-1".into(), - uploaded_at: 10, - attempt_count: 0, - last_checked_at: None, - }, - ), - )) - .unwrap(); - - manager.do_sync_unsynced_wallets().await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); - - let warning = manager.verify_backup_integrity_impl().await.expect("expected passkey warning"); - - assert!(!warning.contains("some wallets are not backed up")); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_does_not_retry_sync_after_auto_backup_failure() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - - let mut metadata = WalletMetadata::preview_new(); - metadata.wallet_type = WalletType::WatchOnly; - Database::global() - .wallets() - .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata]) - .unwrap(); - globals.cloud.fail_wallet_backup_upload("offline"); - - let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); - - assert!(warning.contains("some wallets are not backed up")); - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_warns_when_background_wallet_list_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - globals.cloud.fail_list_wallet_files_non_interactive("offline"); - - let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); - - assert!(warning.contains("wallet backups could not be listed")); - globals.cloud.clear_list_wallet_files_non_interactive_failure(); -} - -#[tokio::test(flavor = "current_thread")] -async fn refresh_cloud_backup_detail_uses_interactive_wallet_listing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - - let keychain = Keychain::global(); - let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); - CloudBackupKeychain::new(keychain.clone()) - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - let master_key = - cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "interactive-revision", 1).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - globals.cloud.fail_list_wallet_files_non_interactive("offline"); - - let Some(CloudBackupDetailResult::Success(detail)) = - manager.refresh_cloud_backup_detail().await - else { - panic!("expected cloud backup detail"); - }; - - assert_eq!(1, detail.up_to_date.len() + detail.needs_sync.len()); - let listed_record_id = detail - .up_to_date - .first() - .map(|wallet| wallet.record_id.clone()) - .or_else(|| detail.needs_sync.first().map(|wallet| wallet.record_id.clone())) - .expect("expected listed wallet"); - assert_eq!(listed_record_id, record_id); - globals.cloud.clear_list_wallet_files_non_interactive_failure(); -} - -#[tokio::test(flavor = "current_thread")] -async fn refresh_cloud_backup_detail_returns_access_error_when_wallet_listing_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace, - CloudStorageError::NotFound("wallet files missing".into()), - ); - - let Some(CloudBackupDetailResult::AccessError(error)) = - manager.refresh_cloud_backup_detail().await - else { - panic!("expected cloud backup detail access error"); - }; - - assert!(error.to_string().contains("wallet files missing"), "{error}"); -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_preserves_unsupported_remote_wallet_backups() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - - let keychain = Keychain::global(); - let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); - CloudBackupKeychain::new(keychain.clone()) - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - let master_key = - cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let warning = manager.verify_backup_integrity_impl().await; - - assert!(warning.is_none()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - - let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::UnsupportedVersion); -} - -#[tokio::test(flavor = "current_thread")] -async fn refresh_cloud_backup_detail_marks_listed_wallet_unknown_when_master_key_is_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - reset_cloud_backup_test_state(&manager, globals); - - let metadata = xpub_only_wallet_metadata(); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let keychain = Keychain::global(); - CloudBackupKeychain::new(keychain.clone()).save_namespace_id(&namespace).unwrap(); - cove_cspp::Cspp::new(keychain.clone()).save_master_key(&master_key).unwrap(); - manager - .persist_cloud_backup_state( - &persisted_enabled_cloud_backup_state(Some(1)), - "set cloud backup enabled for test", - ) - .unwrap(); - manager.sync_persisted_state(); - - persist_xpub_wallets(vec![metadata.clone()]); - - let record_id = wallet_record_id(metadata.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "rev-1", 1).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let cspp = cove_cspp::Cspp::new(keychain.clone()); - cspp.delete_master_key(); - cove_cspp::Cspp::::clear_cached_master_key(); - - let Some(CloudBackupDetailResult::Success(detail)) = - manager.refresh_cloud_backup_detail().await - else { - panic!("expected cloud backup detail"); - }; - - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::RemoteStateUnknown); -} - -#[tokio::test(flavor = "current_thread")] -async fn sync_skips_wallets_with_unknown_remote_truth() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); - globals.cloud.set_wallet_backup(namespace, record_id, b"{".to_vec()); - - manager.do_sync_unsynced_wallets().await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_refreshes_detail_after_auto_backup_success() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let warning = manager.verify_backup_integrity_impl().await; - - assert!(warning.is_none()); - let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - assert_eq!(detail.up_to_date[0].sync_status, CloudBackupWalletStatus::Confirmed); - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_auto_backup_continues_when_other_backup_summary_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - globals.cloud.fail_list_namespaces("offline while listing namespaces"); - - let warning = manager.verify_backup_integrity_impl().await; - - assert!(warning.is_none()); - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); - let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); - assert!(matches!(detail.other_backups, CloudBackupOtherBackupsState::LoadFailed { .. })); - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_does_not_retry_sync_after_auto_backup_success_when_listing_stays_empty() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - - let warning = manager.verify_backup_integrity_impl().await; - - assert!(warning.is_none()); - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn integrity_refreshes_detail_after_auto_backup_failure() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata]); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - CloudBackupKeychain::global() - .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) - .unwrap(); - globals.cloud.fail_wallet_backup_upload("offline"); - - let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); - - assert!(warning.contains("some wallets are not backed up")); - let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::Dirty); -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_preserves_newer_dirty_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let mut metadata = WalletMetadata::preview_new(); - metadata.wallet_type = WalletType::WatchOnly; - Database::global() - .wallets() - .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) - .unwrap(); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace_id, - metadata.id.clone(), - record_id.clone(), - PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), - )) - .unwrap(); - globals.cloud.dirty_wallet_on_next_upload(metadata.id.clone()); - - manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_recovers_deferred_write_to_dirty() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - persist_dirty_blob_state(metadata.id.clone()); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = wallet_record_id(metadata.id.as_ref()); - let PersistedCloudBackupState::Configured(previous_configured) = - Database::global().cloud_backup_state.get().unwrap() - else { - panic!("expected configured cloud backup state"); - }; - globals.cloud.persist_disabling_on_next_upload(PersistedDisablingCloudBackup { - previous_configured, - namespace_id, - disable_generation: 42, - started_at: 100, - delete_started_at: None, - last_error: None, - retry_after: None, - }); - - let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); - - assert!(matches!(error, CloudBackupError::Deferred(_))); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - Database::global().cloud_backup_state.get().unwrap(), - PersistedCloudBackupState::Disabling(_) - )); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); - manager.debug_reset_cloud_backup_state(); -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_retries_stale_uploading_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_uploading_blob_state(metadata.id.clone(), 1); - - manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); - clear_wallet_upload_runtime_for_test_async(&manager).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_recovers_stale_uploading_state_while_offline() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_uploading_blob_state(metadata.id.clone(), 1); - CONNECTIVITY_MANAGER.set_connection_state(false); - - let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); - - assert!(matches!(error, CloudBackupError::Deferred(_))); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn upload_wallet_if_dirty_skips_fresh_uploading_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![metadata.clone()]); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_uploading_blob_state( - metadata.id.clone(), - crate::manager::cloud_backup_manager::current_timestamp(), - ); - - manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::Uploading(CloudBlobUploadingState { .. }), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn backup_wallets_preserves_newer_dirty_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let mut metadata = WalletMetadata::preview_new(); - metadata.wallet_type = WalletType::WatchOnly; - Database::global() - .wallets() - .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) - .unwrap(); - globals.cloud.change_wallet_on_next_upload(metadata.id.clone()); - - manager.do_backup_wallets(&[metadata.clone()]).await.unwrap(); - - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_preserves_newer_dirty_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = WalletMetadata::preview_new(); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace_id, - metadata.id.clone(), - record_id.clone(), - PersistedCloudBlobState::UploadedPendingConfirmation( - CloudBlobUploadedPendingConfirmationState { - revision_hash: "rev-1".into(), - uploaded_at: 10, - attempt_count: 0, - last_checked_at: None, - }, - ), - )) - .unwrap(); - globals.cloud.dirty_wallet_on_next_backup_check(metadata.id.clone()); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_fails_when_auto_sync_upload_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.fail_wallet_backup_upload("upload failed"); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::Failed(DeepVerificationFailure::Retry { - message, detail, .. - }) => { - assert_eq!( - message, - "failed to auto-sync missing wallet backups: cloud storage error: upload failed: upload failed" - ); - let detail = detail.expect("expected detail on retry failure"); - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - } - other => panic!("expected retry failure, got {other:?}"), - } -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_persists_partial_auto_sync_upload_before_later_wallet_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let first_wallet = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - globals.cloud.fail_wallet_backup_upload_after_successes(1, "upload failed"); - - let mut second_wallet = WalletMetadata::preview_new(); - second_wallet.wallet_type = WalletType::WatchOnly; - Database::global() - .wallets() - .save_all_wallets( - first_wallet.network, - first_wallet.wallet_mode, - vec![first_wallet.clone(), second_wallet], - ) - .unwrap(); - - let result = deep_verify_for_test(&manager, true).await; - - let record_id = wallet_record_id(first_wallet.id.as_ref()); - match result { - DeepVerificationResult::Failed(DeepVerificationFailure::Retry { message, .. }) => { - assert!(message.contains("upload failed"), "{message}"); - } - other => panic!("expected retry failure, got {other:?}"), - } - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_awaits_upload_confirmation_when_relist_still_misses_uploaded_wallet() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::AwaitingUploadConfirmation(report) => { - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - } - other => panic!("expected awaiting upload confirmation, got {other:?}"), - } - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - assert!(manager.pending_verification_completion().is_some()); - assert!(manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn manual_verification_clears_interactive_state_when_awaiting_upload_confirmation() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - call!(manager.supervisor.start_verification(true)).await.unwrap(); - wait_for_test_condition( - Duration::from_secs(2), - "verification awaits upload confirmation", - || { - let state = manager.model_snapshot(); - manager.pending_verification_completion().is_some() - && state.pending_upload_verification == PendingUploadVerificationState::Confirming - }, - ) - .await; - - let state = manager.model_snapshot(); - assert!(matches!(state.verification, VerificationState::Idle)); - assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); - assert!(manager.pending_verification_completion().is_some()); - - let detail = state.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); -} - -#[tokio::test(flavor = "current_thread")] -async fn manual_verification_repairs_missing_master_key_wrapper_through_supervisor() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - call!(manager.supervisor.start_verification(true)).await.unwrap(); - - wait_for_test_condition(Duration::from_secs(8), "verification wrapper repair finishes", || { - manager.projected_exclusive_operation().is_none() - && matches!( - manager.model_snapshot().verification, - VerificationState::Verified(DeepVerificationReport { - master_key_wrapper_repaired: true, - .. - }) - ) - }) - .await; - - assert!(globals.cloud.has_master_key_backup(&namespace)); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&cspp_master_key_record_id()).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn manual_verification_loads_wallet_inventory_before_wrapper_repair() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - globals.cloud.fail_list_wallet_files("list failed"); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - call!(manager.supervisor.start_verification(true)).await.unwrap(); - - wait_for_test_condition( - Duration::from_secs(8), - "verification fails before wrapper repair", - || { - matches!( - manager.model_snapshot().verification, - VerificationState::Failed(DeepVerificationFailure::Retry { .. }) - ) - }, - ) - .await; - - match manager.model_snapshot().verification { - VerificationState::Failed(DeepVerificationFailure::Retry { message, .. }) => { - assert!(message.contains("failed to list wallet backups"), "{message}"); - } - other => panic!("expected retry failure, got {other:?}"), - } - assert_eq!(globals.passkey.create_count(), 0); - assert!(!globals.cloud.has_master_key_backup(&namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_repairs_stale_local_master_key_before_recreate_manifest() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - let stale_master_key = cove_cspp::master_key::MasterKey::generate(); - let remote_master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = remote_master_key.namespace_id(); - let prf_key = [7u8; 32]; - let prf_salt = [9u8; 32]; - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&remote_master_key, &prf_key, &prf_salt) - .unwrap(); - - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace.clone(), - CloudStorageError::NotFound(namespace.clone()), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let keychain = Keychain::global().clone(); - CloudBackupKeychain::new(keychain.clone()).save_namespace_id(&namespace).unwrap(); - let cspp = cove_cspp::Cspp::new(keychain); - cspp.save_master_key(&stale_master_key).unwrap(); - manager - .persist_cloud_backup_state( - &persisted_enabled_cloud_backup_state(Some(0)), - "set cloud backup enabled for test", - ) - .unwrap(); - manager.sync_persisted_state(); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!( - result, - DeepVerificationResult::Failed(DeepVerificationFailure::RecreateManifest { .. }) - )); - let repaired = cspp.load_master_key_from_store().unwrap().unwrap(); - assert_eq!(repaired.as_bytes(), remote_master_key.as_bytes()); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_reads_each_wallet_backup_once() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - configure_enabled_cloud_backup(&manager, globals, 1); - seed_verifiable_cloud_master_key(globals); - - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) - .load_master_key_from_store() - .unwrap() - .unwrap(); - let metadata = xpub_only_wallet_metadata(); - let record_id = wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - - let prepared = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &metadata, - metadata.wallet_mode, - ) - .await - .unwrap(); - - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, &prepared.revision_hash, 1).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let downloads_before = globals.cloud.wallet_backup_download_attempt_count(); - - let step = manager.prepare_deep_verify_cloud_backup(true).await; - - assert_eq!(globals.cloud.wallet_backup_download_attempt_count() - downloads_before, 1); - - call!(manager.supervisor.complete_verification(step, true, VerificationAttempt::Initial)) - .await - .unwrap(); - wait_for_test_condition(Duration::from_secs(8), "deep verification completes", || { - matches!(manager.model_snapshot().verification, VerificationState::Verified(_)) - }) - .await; - - let result = match manager.model_snapshot().verification { - VerificationState::Verified(report) => DeepVerificationResult::Verified(report), - other => panic!("expected verified result, got {other:?}"), - }; - - match result { - DeepVerificationResult::Verified(report) => { - assert_eq!(report.wallets_verified, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 0); - - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - } - other => panic!("expected verified result, got {other:?}"), - } - - assert_eq!(globals.cloud.wallet_backup_download_attempt_count() - downloads_before, 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn start_verification_dispatch_resumes_pending_upload_verification() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - persist_pending_master_key_confirmation(namespace_id.clone(), "pending"); - manager.replace_pending_verification_completion(PendingVerificationCompletion::new( - DeepVerificationReport { - master_key_wrapper_repaired: false, - local_master_key_repaired: false, - credential_recovered: false, - wallets_verified: 0, - wallets_failed: 0, - wallets_unsupported: 0, - detail: None, - }, - namespace_id.clone(), - vec![PendingVerificationUpload::master_key_wrapper()], - )); - globals - .cloud - .fail_master_key_download_authorization_required(namespace_id, "authorization required"); - - manager.dispatch(CloudBackupManagerAction::StartVerification( - CloudBackupVerificationSource::Settings, - )); - - wait_for_test_condition( - Duration::from_secs(1), - "expected pending upload verification to pause on authorization", - || { - matches!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::BlockedOnAuthorization - ) - }, - ) - .await; - - assert!(manager.pending_verification_completion().is_some()); - assert!(!matches!(manager.model_snapshot().verification, VerificationState::Verifying)); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_finalizes_awaiting_deep_verify() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - assert!(manager.pending_verification_completion().is_some()); - assert!(manager.has_pending_cloud_upload_verification()); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(manager.pending_verification_completion().is_none()); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Idle - ); - - match manager.model_snapshot().verification { - VerificationState::Verified(report) => { - assert_eq!(report.wallets_verified, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 0); - - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - } - other => { - panic!("expected verified result after pending upload verification, got {other:?}") - } - } - - assert!(!manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_keeps_master_key_wrapper_hash_mismatch_pending() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let expected_master_json = vec![1, 2, 3]; - let expected_revision = master_key_wrapper_revision_hash(&expected_master_json); - persist_pending_master_key_confirmation(namespace_id.clone(), expected_revision); - globals.cloud.set_master_key_backup(namespace_id.clone(), vec![4, 5, 6]); - manager.replace_pending_verification_completion(PendingVerificationCompletion::new( - DeepVerificationReport { - master_key_wrapper_repaired: false, - local_master_key_repaired: false, - credential_recovered: false, - wallets_verified: 0, - wallets_failed: 0, - wallets_unsupported: 0, - detail: None, - }, - namespace_id, - vec![PendingVerificationUpload::master_key_wrapper()], - )); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(has_more_pending); - assert!(manager.pending_verification_completion().is_some()); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Confirming - ); - assert!(matches!(manager.model_snapshot().verification, VerificationState::Idle)); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_expires_stale_completion() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - manager.replace_pending_verification_completion(PendingVerificationCompletion::new( - DeepVerificationReport { - master_key_wrapper_repaired: false, - local_master_key_repaired: false, - credential_recovered: false, - wallets_verified: 0, - wallets_failed: 0, - wallets_unsupported: 0, - detail: None, - }, - namespace_id, - vec![PendingVerificationUpload::master_key_wrapper()], - )); - - let mut state = RustCloudBackupManager::load_persisted_state(); - match &mut state { - PersistedCloudBackupState::Configured(configured) => { - configured - .pending_verification_completion - .as_mut() - .expect("pending verification completion") - .created_at = Some(0); - } - other => panic!("expected configured cloud backup state, got {other:?}"), - } - Database::global().cloud_backup_state.set(&state).unwrap(); - - manager.finalize_pending_verification_if_ready().await; - - assert!(manager.pending_verification_completion().is_none()); - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Idle - ); - match manager.model_snapshot().verification { - VerificationState::Failed(DeepVerificationFailure::Retry { message, .. }) => { - assert_eq!( - message, - "cloud backup upload confirmation expired; start verification again" - ); - } - other => panic!("expected retry failure for stale completion, got {other:?}"), - } -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_refreshes_sync_health_to_all_uploaded() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 1); - let master_revision = seed_verifiable_cloud_master_key(globals); - - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let metadata = xpub_only_wallet_metadata(); - let record_id = wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) - .load_master_key_from_store() - .unwrap() - .unwrap(); - let prepared = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &metadata, - metadata.wallet_mode, - ) - .await - .unwrap(); - - globals.cloud.set_wallet_backup( - namespace_id.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, &prepared.revision_hash, 1).await, - ); - globals - .cloud - .set_wallet_files(namespace_id.clone(), vec![wallet_filename_from_record_id(&record_id)]); - persist_pending_master_key_confirmation(namespace_id.clone(), master_revision); - manager - .mark_blob_uploaded_pending_confirmation( - &namespace_id, - CloudBackupRecordKey::Wallet(metadata.id, record_id.clone()), - prepared.revision_hash.clone(), - crate::manager::cloud_backup_manager::current_timestamp(), - ) - .unwrap(); - manager.replace_pending_verification_completion(PendingVerificationCompletion::new( - DeepVerificationReport { - master_key_wrapper_repaired: false, - local_master_key_repaired: false, - credential_recovered: false, - wallets_verified: 0, - wallets_failed: 0, - wallets_unsupported: 0, - detail: None, - }, - namespace_id, - vec![ - PendingVerificationUpload::master_key_wrapper(), - PendingVerificationUpload::new(record_id, prepared.revision_hash), - ], - )); - - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(manager.pending_verification_completion().is_none()); - assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::AllUploaded,); - - for _ in 0..20 { - if manager.model_snapshot().sync_health == CloudSyncHealth::AllUploaded { - break; - } - - tokio::time::sleep(Duration::from_millis(10)).await; - } - - assert_eq!(manager.model_snapshot().sync_health, CloudSyncHealth::AllUploaded); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_survives_restart() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - assert!(manager.pending_verification_completion().is_some()); - - let restarted_manager = init_manager(); - - assert!(restarted_manager.pending_verification_completion().is_some()); - restarted_manager.sync_persisted_state(); - let has_more_pending = verify_pending_uploads_once_for_test_async(&restarted_manager).await; - - assert!(!has_more_pending); - assert!(restarted_manager.pending_verification_completion().is_none()); - match restarted_manager.model_snapshot().verification { - VerificationState::Verified(report) => { - assert_eq!(report.wallets_verified, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 0); - - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - } - other => { - panic!("expected verified result after restart, got {other:?}") - } - } - assert!(!restarted_manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_retries_until_expected_revision_is_readable() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) - .load_master_key_from_store() - .unwrap() - .unwrap(); - let current_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( - &metadata, - metadata.wallet_mode, - ) - .await - .unwrap() - .revision_hash; - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - globals.cloud.set_wallet_backup_download_override( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "stale-revision", 1).await, - ); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(has_more_pending); - assert!(manager.pending_verification_completion().is_some()); - assert!(manager.has_pending_cloud_upload_verification()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); - assert!(!matches!( - manager.model_snapshot().verification, - VerificationState::Verified(_) | VerificationState::Failed(_) - )); - - globals.cloud.set_wallet_backup_download_override( - namespace, - record_id, - encrypted_wallet_backup_bytes(&metadata, &master_key, ¤t_revision, 1).await, - ); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(manager.pending_verification_completion().is_none()); - match manager.model_snapshot().verification { - VerificationState::Verified(report) => { - assert_eq!(report.wallets_verified, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 0); - } - other => panic!("expected verified result after retry, got {other:?}"), - } - assert!(!manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_accepts_newer_revision_after_wallet_changes() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.change_wallet_on_next_upload(metadata.id.clone()); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - assert!(manager.pending_verification_completion().is_some()); - assert!(manager.has_pending_cloud_upload_verification()); - - manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); - - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_) - | PersistedCloudBlobState::Confirmed(_), - .. - }) - )); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(manager.pending_verification_completion().is_none()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Confirmed(_), .. }) - )); - - match manager.model_snapshot().verification { - VerificationState::Verified(report) => { - assert_eq!(report.wallets_verified, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 0); - - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!(detail.up_to_date[0].record_id, record_id); - } - other => panic!("expected verified result after newer upload, got {other:?}"), - } - - assert!(!manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_marks_invalid_wallet_json_failed() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - globals.cloud.set_wallet_backup(namespace, record_id.clone(), b"{".to_vec()); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(manager.pending_verification_completion().is_none()); - - match manager.model_snapshot().verification { - VerificationState::Verified(report) => { - assert_eq!(report.wallets_verified, 0); - assert_eq!(report.wallets_failed, 1); - assert_eq!(report.wallets_unsupported, 0); - } - other => { - panic!("expected verified result after pending upload verification, got {other:?}") - } - } - - assert!(!manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn pending_upload_verification_marks_terminal_live_upload_failures_failed() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - configure_enabled_cloud_backup(&manager, globals, 0); - - let metadata = xpub_only_wallet_metadata(); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - persist_xpub_wallets(vec![metadata.clone()]); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace_id.clone(), - metadata.id, - record_id.clone(), - PersistedCloudBlobState::UploadedPendingConfirmation( - CloudBlobUploadedPendingConfirmationState { - revision_hash: "rev-1".into(), - uploaded_at: 10, - attempt_count: 0, - last_checked_at: None, - }, - ), - )) - .unwrap(); - globals.cloud.set_wallet_backup(namespace_id, record_id.clone(), b"{".to_vec()); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(!has_more_pending); - assert!(!manager.has_pending_cloud_upload_verification()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), - .. - }) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn failed_pending_upload_without_remote_backup_remains_pending() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - - let result = deep_verify_for_test(&manager, true).await; - - assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); - assert!(manager.pending_verification_completion().is_some()); - - CloudStorage::global_silent_client() - .delete_wallet_backup(namespace_id.clone(), record_id.clone()) - .await - .unwrap(); - Database::global() - .cloud_blob_sync_states - .set(&PersistedCloudBlobSyncState::wallet( - namespace_id, - metadata.id, - record_id.clone(), - PersistedCloudBlobState::Failed(CloudBlobFailedState { - revision_hash: Some("rev-1".into()), - retryable: false, - error: "terminal upload failure".into(), - issue: None, - failed_at: 10, - }), - )) - .unwrap(); - - let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; - - assert!(has_more_pending); - assert!(manager.pending_verification_completion().is_some()); - assert!(manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_preserves_unsupported_remote_wallet_backups() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let keychain = Keychain::global(); - let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); - let master_key = - cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::Verified(report) => { - assert_eq!(report.wallets_verified, 0); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.wallets_unsupported, 1); - } - other => panic!("expected verified result, got {other:?}"), - } - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); - assert!(manager.pending_verification_completion().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_retries_when_remote_wallet_truth_is_unknown() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); - globals.cloud.set_wallet_backup(namespace, record_id.clone(), b"{".to_vec()); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::Failed(DeepVerificationFailure::Retry { - message, detail, .. - }) => { - assert_eq!(message, "failed to refresh remote wallet truth for some wallets"); - - let detail = detail.expect("expected verification detail"); - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - assert_eq!( - detail.needs_sync[0].sync_status, - CloudBackupWalletStatus::RemoteStateUnknown - ); - } - other => panic!("expected retry failure, got {other:?}"), - } - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_succeeds_after_auto_sync_relist_confirms_wallet() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::Verified(report) => { - let detail = report.detail.expect("expected verification detail"); - assert_eq!(detail.up_to_date.len(), 1); - assert!(detail.needs_sync.is_empty()); - assert_eq!( - detail.up_to_date[0].record_id, - cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()) - ); - } - other => panic!("expected verified result, got {other:?}"), - } - - assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Confirmed(_), .. }) - )); - assert!(!manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn deep_verify_awaits_upload_confirmation_when_remote_revision_is_stale() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); - let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); - let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) - .load_master_key_from_store() - .unwrap() - .unwrap(); - globals.cloud.set_reflect_uploaded_wallets_in_listing(true); - globals.cloud.set_wallet_backup_download_override( - namespace, - record_id.clone(), - encrypted_wallet_backup_bytes(&metadata, &master_key, "stale-revision", 1).await, - ); - - let result = deep_verify_for_test(&manager, true).await; - - match result { - DeepVerificationResult::AwaitingUploadConfirmation(report) => { - let detail = report.detail.expect("expected verification detail"); - assert!(detail.up_to_date.is_empty()); - assert_eq!(detail.needs_sync.len(), 1); - assert_eq!(detail.needs_sync[0].record_id, record_id); - } - other => panic!("expected awaiting upload confirmation, got {other:?}"), - } - - assert!(manager.pending_verification_completion().is_some()); - assert!(matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::UploadedPendingConfirmation(_), - .. - }) - )); - assert!(manager.has_pending_cloud_upload_verification()); -} - -#[tokio::test(flavor = "current_thread")] -async fn discard_pending_enable_clears_pending_session_and_local_master_key() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&master_key).unwrap(); - replace_pending_enable_session_for_test( - &manager, - pending_enable_awaiting_confirmation( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - manager.discard_pending_enable_cloud_backup(); - - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn discard_pending_enable_retry_upload_deletes_remote_master_key() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&master_key).unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - replace_pending_enable_session_for_test( - &manager, - PendingEnableSession::retry_upload( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - manager.discard_pending_enable_cloud_backup(); - - wait_for_test_condition( - Duration::from_secs(1), - "remote master key backup should be deleted", - || !globals.cloud.has_master_key_backup(&namespace), - ) - .await; - - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn discard_pending_enable_retry_upload_treats_missing_remote_master_key_as_deleted() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&master_key).unwrap(); - globals.cloud.fail_delete_wallet_backup_not_found("already deleted"); - replace_pending_enable_session_for_test( - &manager, - PendingEnableSession::retry_upload( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - manager.discard_pending_enable_cloud_backup(); - - wait_for_test_condition( - Duration::from_secs(1), - "pending enable session should be discarded after missing remote cleanup", - || cspp.load_master_key_from_store().unwrap().is_none(), - ) - .await; - - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); -} - -#[tokio::test(flavor = "current_thread")] -async fn discard_pending_enable_retry_upload_keeps_pending_state_when_remote_cleanup_fails() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&master_key).unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); - globals.cloud.fail_delete_wallet_backup("delete failed"); - replace_pending_enable_session_for_test( - &manager, - PendingEnableSession::retry_upload( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - manager.discard_pending_enable_cloud_backup(); - - wait_for_test_condition( - Duration::from_secs(1), - "discard pending enable cleanup failure should surface", - || matches!(manager.state().lifecycle, CloudBackupLifecycle::Failed(_)), - ) - .await; - - assert!(globals.cloud.has_master_key_backup(&namespace)); - assert!(cspp.load_master_key_from_store().unwrap().is_some()); - assert!(take_pending_enable_session_for_test(&manager).await.is_some()); -} - -#[test] -fn clear_in_process_state_for_local_reset_clears_enable_state() { - let _guard = test_lock().lock(); - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - manager.apply_enable_outcome(CloudBackupEnableOutcome::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - )); - - manager.clear_in_process_state_for_local_reset(); - - assert_eq!(manager.model_snapshot().enable_state, CloudBackupEnableState::Idle); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_preserves_awaiting_force_new_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - let existing_master_key = cove_cspp::master_key::MasterKey::generate(); - let existing_namespace = existing_master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); - globals - .cloud - .set_master_key_backup(existing_namespace, serde_json::to_vec(&encrypted_master).unwrap()); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let expected_namespace = master_key.namespace_id(); - let expected_credential_id = vec![1, 2, 3]; - replace_pending_enable_session_for_test( - &manager, - pending_enable_awaiting_confirmation( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: expected_credential_id.clone(), - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - enable_cloud_backup_create_new(&manager).await.unwrap(); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); - assert_eq!(pending_master_key.namespace_id(), expected_namespace); - assert_eq!(pending_passkey.credential_id, expected_credential_id); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_create_new_preserves_awaiting_force_new_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let expected_namespace = master_key.namespace_id(); - let expected_credential_id = vec![1, 2, 3]; - replace_pending_enable_session_for_test( - &manager, - pending_enable_awaiting_confirmation( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: expected_credential_id.clone(), - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - enable_cloud_backup_create_new(&manager).await.unwrap(); - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); - assert_eq!(pending_master_key.namespace_id(), expected_namespace); - assert_eq!(pending_passkey.credential_id, expected_credential_id); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_no_discovery_preserves_awaiting_force_new_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Err(PasskeyError::UserCancelled)); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let expected_namespace = master_key.namespace_id(); - let expected_credential_id = vec![1, 2, 3]; - replace_pending_enable_session_for_test( - &manager, - pending_enable_awaiting_confirmation( - master_key, - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: expected_credential_id.clone(), - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - let create_count = globals.passkey.create_count(); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), create_count); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); - match manager.model_snapshot().root_prompt { - CloudBackupRootPrompt::ExistingBackupFound(context, _) => { - assert_eq!(context, CloudBackupEnableContext::settings_manual()); - } - other => panic!("expected existing backup prompt, got {other:?}"), - } - - let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); - let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); - assert_eq!(pending_master_key.namespace_id(), expected_namespace); - assert_eq!(pending_passkey.credential_id, expected_credential_id); -} - -#[tokio::test(flavor = "current_thread")] -async fn force_new_after_other_namespace_enter_detail_reuses_runtime_authorization() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - let existing_master_key = cove_cspp::master_key::MasterKey::generate(); - let existing_namespace = existing_master_key.namespace_id(); - let encrypted_existing_master = - cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); - globals.cloud.set_master_key_backup( - existing_namespace, - serde_json::to_vec(&encrypted_existing_master).unwrap(), - ); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 0); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!(globals.passkey.discover_count(), 0); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); - match manager.model_snapshot().root_prompt { - CloudBackupRootPrompt::ExistingBackupFound(context, _) => { - assert_eq!(context, CloudBackupEnableContext::settings_manual()); - } - other => panic!("expected existing backup prompt, got {other:?}"), - } - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 0); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!(globals.passkey.discover_count(), 0); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - - enable_cloud_backup_force_new(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - let create_count = globals.passkey.create_count(); - let authenticate_count = globals.passkey.authenticate_count(); - let discover_count = globals.passkey.discover_count(); - - call!(manager.supervisor.start_enter_detail()).await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - - assert_eq!(globals.passkey.create_count(), create_count); - assert_eq!(globals.passkey.authenticate_count(), authenticate_count); - assert_eq!(globals.passkey.discover_count(), discover_count); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn force_new_after_existing_backup_prompt_registers_without_discovery() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - let existing_master_key = cove_cspp::master_key::MasterKey::generate(); - let existing_namespace = existing_master_key.namespace_id(); - let encrypted_existing_master = - cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); - globals.cloud.set_master_key_backup( - existing_namespace, - serde_json::to_vec(&encrypted_existing_master).unwrap(), - ); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 0); - assert_eq!(globals.passkey.discover_count(), 0); - assert!(matches!( - manager.model_snapshot().root_prompt, - CloudBackupRootPrompt::ExistingBackupFound(_, _) - )); - - enable_cloud_backup_force_new(&manager).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 1); - assert_eq!(globals.passkey.discover_count(), 0); - assert_eq!( - manager.model_snapshot().enable_state, - CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( - SavedPasskeyConfirmationMode::Manual, - ) - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn existing_backup_prompt_preserves_onboarding_enable_context() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - let existing_master_key = cove_cspp::master_key::MasterKey::generate(); - let existing_namespace = existing_master_key.namespace_id(); - let encrypted_existing_master = - cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) - .unwrap(); - globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); - globals.cloud.set_master_key_backup( - existing_namespace, - serde_json::to_vec(&encrypted_existing_master).unwrap(), - ); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - - let context = CloudBackupEnableContext { - saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, - verification_source: CloudBackupVerificationSource::Onboarding, - }; - enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); - - assert_eq!(globals.passkey.create_count(), 0); - assert_eq!(globals.passkey.authenticate_count(), 0); - assert_eq!(globals.passkey.discover_count(), 0); - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - - match manager.model_snapshot().root_prompt { - CloudBackupRootPrompt::ExistingBackupFound(prompt_context, _) => { - assert_eq!(prompt_context, context); - } - other => panic!("expected existing backup prompt, got {other:?}"), - } - - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - enable_cloud_backup_force_new_with_context(&manager, context).await.unwrap(); - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - assert_eq!(globals.passkey.authenticate_count(), 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn existing_passkey_onboarding_recovery_completes_verification() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - let prf_key = [7u8; 32]; - let existing_master_key = cove_cspp::master_key::MasterKey::generate(); - let existing_namespace = existing_master_key.namespace_id(); - let encrypted_existing_master = - cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &prf_key, &[9; 32]) - .unwrap(); - globals.cloud.set_master_key_backup( - existing_namespace, - serde_json::to_vec(&encrypted_existing_master).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let context = CloudBackupEnableContext { - saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, - verification_source: CloudBackupVerificationSource::Onboarding, - }; - enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); - - assert!(matches!( - manager.model_snapshot().root_prompt, - CloudBackupRootPrompt::ExistingBackupFound(_, _) - )); - - manager.dispatch(CloudBackupManagerAction::AcceptEnablePrompt( - CloudBackupEnablePromptChoice::UseExisting, - )); - - wait_for_test_condition( - Duration::from_secs(2), - "existing passkey onboarding recovery should verify", - || { - let snapshot = manager.model_snapshot(); - manager.current_status() == CloudBackupStatus::Enabled - && matches!(snapshot.verification, VerificationState::Verified(_)) - }, - ) - .await; - - assert_eq!( - manager.model_snapshot().pending_upload_verification, - PendingUploadVerificationState::Idle - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn detail_entry_after_restart_without_active_authorization_prompts_normally() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - globals.passkey.set_create_result(Ok(vec![1, 2, 3])); - globals.passkey.set_authenticate_result(Ok(vec![7; 32])); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - confirm_saved_passkey_session(&manager).await; - - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); - - let restarted_manager = init_manager(); - restarted_manager.sync_persisted_state(); - restarted_manager.clear_pending_verification_completion(); - restarted_manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); - restarted_manager.apply_verification_outcome(CloudBackupVerificationOutcome::Idle); - Database::global().cloud_blob_sync_states.delete_all().unwrap(); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let discover_count = globals.passkey.discover_count(); - - call!(restarted_manager.supervisor.start_enter_detail()).await.unwrap(); - wait_for_discover_count(globals, discover_count + 1).await; - - assert_eq!(globals.passkey.discover_count(), discover_count + 1); -} - -#[tokio::test(flavor = "current_thread")] -async fn enable_force_new_consumes_staged_session() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - CONNECTIVITY_MANAGER.set_connection_state(true); - - replace_pending_enable_session_for_test( - &manager, - pending_enable_awaiting_confirmation( - cove_cspp::master_key::MasterKey::generate(), - UnpersistedPrfKey { - prf_key: [7; 32], - prf_salt: [9; 32], - credential_id: vec![1, 2, 3], - provider_hint: None, - }, - CloudBackupEnableContext::settings_manual(), - ), - ) - .await; - - enable_cloud_backup_force_new(&manager).await.unwrap(); - - assert!(take_pending_enable_session_for_test(&manager).await.is_none()); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); -} - -#[tokio::test(flavor = "current_thread")] -async fn cancelled_enable_create_new_rolls_back_new_local_master_key() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - enable_cloud_backup_create_new(&manager).await.unwrap(); - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); - assert!(matches!( - manager.model_snapshot().root_prompt, - CloudBackupRootPrompt::PasskeyChoice(CloudBackupPasskeyChoiceIntent::Enable(_, _)) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn cancelled_enable_no_discovery_rolls_back_new_local_master_key() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - globals.passkey.set_create_result(Err(PasskeyError::UserCancelled)); - - enable_cloud_backup_no_discovery(&manager).await.unwrap(); - - let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); - assert!(matches!( - manager.model_snapshot().root_prompt, - CloudBackupRootPrompt::PasskeyChoice(CloudBackupPasskeyChoiceIntent::Enable(_, _)) - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn cancelled_passkey_restore_does_not_fall_back_to_local_master_key() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let local_master_key = cove_cspp::master_key::MasterKey::generate(); - let local_namespace_id = local_master_key.namespace_id(); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&local_master_key).unwrap(); - globals.cloud.set_wallet_files(local_namespace_id.clone(), vec!["wallet-test.json".into()]); - - let remote_master_key = cove_cspp::master_key::MasterKey::generate(); - let remote_namespace_id = remote_master_key.namespace_id(); - let remote_prf_key = [7u8; 32]; - let remote_prf_salt = [9u8; 32]; - let encrypted_master = cove_cspp::master_key_crypto::encrypt_master_key( - &remote_master_key, - &remote_prf_key, - &remote_prf_salt, - ) - .unwrap(); - globals.cloud.set_master_key_backup( - remote_namespace_id.clone(), - serde_json::to_vec(&encrypted_master).unwrap(), - ); - globals.cloud.set_wallet_files(remote_namespace_id, vec!["wallet-remote.json".into()]); - globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(matches!(error, CloudBackupError::PasskeyDiscoveryCancelled)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), None); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_counts_unsupported_wallet_versions_as_failures() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let supported_wallet = xpub_only_wallet_metadata(); - let unsupported_wallet = xpub_only_wallet_metadata(); - Keychain::global() - .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) - .unwrap(); - Keychain::global() - .save_wallet_xpub(&unsupported_wallet.id, sample_xpub(&unsupported_wallet).parse().unwrap()) - .unwrap(); - - let supported_record_id = - cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); - let unsupported_record_id = - cove_cspp::backup_data::wallet_record_id(unsupported_wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - supported_record_id.clone(), - encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) - .await, - ); - globals.cloud.set_wallet_backup( - namespace.clone(), - unsupported_record_id.clone(), - encrypted_wallet_backup_bytes(&unsupported_wallet, &master_key, "unsupported-revision", 2) - .await, - ); - globals.cloud.set_wallet_files( - namespace, - vec![ - wallet_filename_from_record_id(&supported_record_id), - wallet_filename_from_record_id(&unsupported_record_id), - ], - ); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 1); - assert_eq!(report.failed_wallet_errors.len(), 1); - assert!(report.failed_wallet_errors[0].contains("unsupported wallet backup version 2")); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); - assert!( - Database::global() - .wallets() - .get(&supported_wallet.id, supported_wallet.network, supported_wallet.wallet_mode,) - .unwrap() - .is_some() - ); - assert!( - Database::global() - .wallets() - .get( - &unsupported_wallet.id, - unsupported_wallet.network, - unsupported_wallet.wallet_mode, - ) - .unwrap() - .is_none() - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_queues_reupload_when_cloud_upload_confirmation_lags() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - - let record_id = wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &master_key, "restored-revision", 1).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - globals.cloud.set_uploaded_wallets_pending_confirmation(true); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - - assert_eq!(report.wallets_restored, 1); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap(); - assert!( - matches!( - sync_state, - Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) - ), - "unexpected sync state: {sync_state:?}" - ); - assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_with_one_passkey_restores_wallets_from_all_matching_namespaces() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let prf_key = [7u8; 32]; - let first_master_key = cove_cspp::master_key::MasterKey::generate(); - let second_master_key = cove_cspp::master_key::MasterKey::generate(); - let first_namespace = first_master_key.namespace_id(); - let second_namespace = second_master_key.namespace_id(); - let first_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) - .unwrap(); - let second_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) - .unwrap(); - - globals.cloud.set_master_key_backup( - first_namespace.clone(), - serde_json::to_vec(&first_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - second_namespace.clone(), - serde_json::to_vec(&second_encrypted).unwrap(), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let first_wallet = xpub_only_wallet_metadata(); - let second_wallet = xpub_only_wallet_metadata(); - let sample_xpub_from_entropy = |metadata: &WalletMetadata, byte| { - let entropy = [byte; 16]; - let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); - - crate::mnemonic::MnemonicExt::xpub(&mnemonic, metadata.network.into()).to_string() - }; - Keychain::global() - .save_wallet_xpub( - &first_wallet.id, - sample_xpub_from_entropy(&first_wallet, 1).parse().unwrap(), - ) - .unwrap(); - Keychain::global() - .save_wallet_xpub( - &second_wallet.id, - sample_xpub_from_entropy(&second_wallet, 2).parse().unwrap(), - ) - .unwrap(); - - let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); - let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - first_namespace.clone(), - first_record_id.clone(), - encrypted_wallet_backup_bytes(&first_wallet, &first_master_key, "first-revision", 1).await, - ); - globals.cloud.set_wallet_backup( - second_namespace.clone(), - second_record_id.clone(), - encrypted_wallet_backup_bytes(&second_wallet, &second_master_key, "second-revision", 1) - .await, - ); - globals - .cloud - .set_wallet_files(first_namespace, vec![wallet_filename_from_record_id(&first_record_id)]); - globals.cloud.set_wallet_files( - second_namespace, - vec![wallet_filename_from_record_id(&second_record_id)], - ); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - assert_eq!(report.wallets_restored, 2); - assert_eq!(report.wallets_failed, 0); - assert!(report.failed_wallet_errors.is_empty(), "{:?}", report.failed_wallet_errors); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(2)); - let active_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - for record_id in [&first_record_id, &second_record_id] { - let sync_state = Database::global().cloud_blob_sync_states.get(record_id).unwrap().unwrap(); - assert_eq!(sync_state.namespace_id, active_namespace); - assert!( - matches!(sync_state.state, PersistedCloudBlobState::Dirty(_)), - "unexpected sync state: {sync_state:?}" - ); - } -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_missing_wallet_listing_fails_closed_without_finalizing_empty_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - configure_enabled_cloud_backup(&manager, globals, 7); - let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.cloud.fail_list_wallet_files_for_namespace( - namespace, - CloudStorageError::NotFound("wallet files missing".into()), - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(error.to_string().contains("wallet files missing"), "{error}"); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_activation_upload_failure_keeps_restore_successful_and_queues_upload() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let wallet = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &master_key, "restore-revision", 1).await, - ); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); - globals.cloud.fail_wallet_backup_upload("activation upload failed"); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let (sender, receiver) = flume::bounded(1); - call!(manager.supervisor.start_restore_from_cloud_backup_with_events(sender)).await.unwrap(); - - let report = tokio::time::timeout(Duration::from_secs(1), async { - loop { - match receiver.recv_async().await.expect("receive restore event") { - CloudBackupRestoreEvent::Progress(_) => {} - CloudBackupRestoreEvent::Complete(report) => break report, - CloudBackupRestoreEvent::Failed(message) => { - panic!("restore should complete before background upload failure: {message}"); - } - other => panic!("expected restore completion event, got {other:?}"), - } - } - }) - .await - .expect("restore completion event"); - - assert_eq!(report.wallets_restored, 1); - wait_for_test_condition( - Duration::from_secs(5), - "background reupload should fail after restore and remain queued for retry", - || { - let upload_attempted = globals.cloud.wallet_backup_upload_attempt_count() > 0; - let retryable_failure = matches!( - Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), - Some(PersistedCloudBlobSyncState { - state: PersistedCloudBlobState::Failed(CloudBlobFailedState { - retryable: true, - .. - }), - .. - }) - ); - - upload_attempted && retryable_failure - }, - ) - .await; - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace.clone())); - let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap().unwrap(); - assert_eq!(sync_state.namespace_id, namespace); - assert!( - matches!( - sync_state.state, - PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: true, .. }) - ), - "unexpected sync state: {sync_state:?}" - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_all_duplicate_wallets_preserves_existing_configured_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - configure_enabled_cloud_backup(&manager, globals, 7); - let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let duplicate_wallet = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![duplicate_wallet.clone()]); - - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) - .await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - - assert_eq!(report.wallets_restored, 0); - assert_eq!(report.wallets_failed, 0); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_all_duplicate_wallets_activates_namespace_when_persisted_state_is_empty() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - let duplicate_wallet = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![duplicate_wallet.clone()]); - - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) - .await, - ); - globals - .cloud - .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - - assert_eq!(report.wallets_restored, 0); - assert_eq!(report.wallets_failed, 0); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace.clone())); - let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap().unwrap(); - assert_eq!(sync_state.namespace_id, namespace); - assert!( - matches!(sync_state.state, PersistedCloudBlobState::Dirty(_)), - "unexpected sync state: {sync_state:?}" - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_duplicate_wallets_with_failures_preserves_existing_configured_state() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - configure_enabled_cloud_backup(&manager, globals, 7); - let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); - let duplicate_wallet = xpub_only_wallet_metadata(); - let missing_wallet = xpub_only_wallet_metadata(); - persist_xpub_wallets(vec![duplicate_wallet.clone()]); - - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - let duplicate_record_id = - cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); - let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.cloud.set_wallet_backup( - namespace.clone(), - duplicate_record_id.clone(), - encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) - .await, - ); - globals.cloud.set_wallet_files( - namespace, - vec![ - wallet_filename_from_record_id(&duplicate_record_id), - wallet_filename_from_record_id(&missing_record_id), - ], - ); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - - assert_eq!(report.wallets_restored, 0); - assert_eq!(report.wallets_failed, 1); - assert!(report.failed_wallet_errors[0].contains("was listed but missing from cloud backup")); - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Enabled - ); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_empty_namespace_list_returns_no_backup_found() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(matches!(error, CloudBackupError::NoBackupFound)); - assert_eq!(CloudBackupKeychain::global().namespace_id(), None); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_retries_platform_authorization_discover_failures() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let prf_key = [7u8; 32]; - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); - - globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); - globals.passkey.push_discover_result(Err(platform_authorization_failed())); - globals.passkey.push_discover_result(Err(platform_authorization_failed())); - globals.passkey.push_discover_result(Err(platform_authorization_failed())); - globals.passkey.push_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &master_key, "revision", 1).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 0); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_does_not_persist_first_passkey_match_before_restore_work_succeeds() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let prf_key = [7u8; 32]; - let first_master_key = cove_cspp::master_key::MasterKey::generate(); - let second_master_key = cove_cspp::master_key::MasterKey::generate(); - let first_namespace = first_master_key.namespace_id(); - let second_namespace = second_master_key.namespace_id(); - let first_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) - .unwrap(); - let second_encrypted = - cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) - .unwrap(); - - globals.cloud.set_master_key_backup( - first_namespace.clone(), - serde_json::to_vec(&first_encrypted).unwrap(), - ); - globals.cloud.set_master_key_backup( - second_namespace.clone(), - serde_json::to_vec(&second_encrypted).unwrap(), - ); - globals.cloud.set_wallet_files(first_namespace, vec!["wallet-1.json".into()]); - globals.cloud.set_wallet_files(second_namespace, vec!["wallet-2.json".into()]); - globals.cloud.fail_list_wallet_files("list failed"); - globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { - prf_output: prf_key.to_vec(), - credential_id: vec![1, 2, 3], - })); - globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(error.to_string().contains("list failed"), "{error}"); - assert_eq!(CloudBackupKeychain::global().namespace_id(), None); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_counts_listed_missing_wallet_backups_as_failures() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let supported_wallet = xpub_only_wallet_metadata(); - let missing_wallet = xpub_only_wallet_metadata(); - Keychain::global() - .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) - .unwrap(); - let supported_record_id = - cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); - let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - supported_record_id.clone(), - encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) - .await, - ); - globals.cloud.set_wallet_files( - namespace, - vec![ - wallet_filename_from_record_id(&supported_record_id), - wallet_filename_from_record_id(&missing_record_id), - ], - ); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 1); - assert!(report.failed_wallet_errors[0].contains("was listed but missing from cloud backup")); - assert!(report.labels_failed_wallet_names.is_empty()); - assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_sanitizes_non_connectivity_wallet_download_errors() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let supported_wallet = xpub_only_wallet_metadata(); - let failed_wallet = xpub_only_wallet_metadata(); - Keychain::global() - .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) - .unwrap(); - - let supported_record_id = - cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); - let failed_record_id = cove_cspp::backup_data::wallet_record_id(failed_wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - supported_record_id.clone(), - encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) - .await, - ); - globals.cloud.fail_wallet_backup_download( - namespace.clone(), - failed_record_id.clone(), - CloudStorageError::DownloadFailed(format!("raw record id {failed_record_id}")), - ); - globals.cloud.set_wallet_files( - namespace, - vec![ - wallet_filename_from_record_id(&supported_record_id), - wallet_filename_from_record_id(&failed_record_id), - ], - ); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 1); - assert_eq!(report.failed_wallet_errors, vec!["wallet backup could not be read"]); - assert!(!report.failed_wallet_errors[0].contains(&failed_record_id)); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_reports_label_warning_without_failing_wallet_restore() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let wallet = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - let entry = wallet_entry_with_labels(&wallet, Some("{")); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes_for_entry(&entry, &master_key, 1), - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let operation = new_restore_operation_for_test(&manager).await; - let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); - assert_eq!(report.wallets_restored, 1); - assert_eq!(report.wallets_failed, 0); - assert_eq!(report.labels_failed_wallet_names, vec![wallet.name.clone()]); - assert_eq!(report.labels_failed_errors.len(), 1); - assert!( - report.labels_failed_errors[0].contains("Failed to parse labels") - || report.labels_failed_errors[0].contains("failed to parse") - ); - assert!( - Database::global() - .wallets() - .get(&wallet.id, wallet.network, wallet.wallet_mode) - .unwrap() - .is_some() - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_cloud_wallet_returns_label_warning_without_failing_restore() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - CloudBackupKeychain::global().save_namespace_id(&namespace).unwrap(); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - manager - .persist_cloud_backup_state( - &persisted_enabled_cloud_backup_state(None), - "enable cloud backup for restore cloud wallet test", - ) - .unwrap(); - - let wallet = xpub_only_wallet_metadata(); - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - let entry = wallet_entry_with_labels(&wallet, Some("{")); - globals.cloud.set_wallet_backup( - namespace, - record_id.clone(), - encrypted_wallet_backup_bytes_for_entry(&entry, &master_key, 1), - ); - - let outcome = manager.do_restore_cloud_wallet(&record_id).await.unwrap(); - - let WalletRestoreOutcome::Restored { labels_warning } = outcome else { - panic!("expected restored wallet"); - }; - let warning = labels_warning.expect("expected label warning"); - assert_eq!(warning.wallet_name, wallet.name); - assert!( - warning.error.contains("Failed to parse labels") - || warning.error.contains("failed to parse") - ); - assert!( - Database::global() - .wallets() - .get(&wallet.id, wallet.network, wallet.wallet_mode) - .unwrap() - .is_some() - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_fails_when_all_wallet_backups_are_unsupported() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let wallet = xpub_only_wallet_metadata(); - Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); - - let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); - globals.cloud.set_wallet_backup( - namespace.clone(), - record_id.clone(), - encrypted_wallet_backup_bytes(&wallet, &master_key, "unsupported-revision", 2).await, - ); - globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(matches!( - error, - CloudBackupError::Internal(message) if message == "all wallets failed to restore" - )); - - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); -} - -#[tokio::test(flavor = "current_thread")] -async fn restore_fails_when_all_listed_wallet_backups_are_missing() { - let _guard = async_test_lock().lock().await; - cove_tokio::init(); - let globals = test_globals(); - let manager = init_manager(); - - reset_cloud_backup_test_state(&manager, globals); - - let master_key = cove_cspp::master_key::MasterKey::generate(); - let namespace = master_key.namespace_id(); - let encrypted_master = - cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); - globals - .cloud - .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); - cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); - - let missing_wallet = xpub_only_wallet_metadata(); - let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); - globals - .cloud - .set_wallet_files(namespace, vec![wallet_filename_from_record_id(&missing_record_id)]); - - let operation = new_restore_operation_for_test(&manager).await; - let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); - - assert!(matches!( - error, - CloudBackupError::Internal(message) if message == "all wallets failed to restore" - )); - - assert_eq!( - Database::global().cloud_backup_state.get().unwrap().status(), - PersistedCloudBackupStatus::Disabled - ); -} +mod cleanup; +mod connectivity; +mod deep_verify; +mod disable; +mod enable; +mod enable_session; +mod master_key; +mod other_backups; +mod passkey; +mod pending_upload; +mod restore; +mod sync_health; +mod upload; diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/cleanup.rs b/rust/src/manager/cloud_backup_manager/ops/tests/cleanup.rs new file mode 100644 index 000000000..f55d47079 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/cleanup.rs @@ -0,0 +1,320 @@ +use super::*; + +struct CleanupTestSource { + namespace: String, + record_id: String, + revision_hash: Option, +} + +async fn enqueue_cleanup_for_test( + manager: &RustCloudBackupManager, + active_namespace: &str, + active_master_key: &cove_cspp::master_key::MasterKey, + source: CleanupTestSource, + wait_message: &str, + mut wait_condition: impl FnMut() -> bool, +) { + call!(manager.supervisor.enqueue_cleanup_for_test(CloudBackupCleanupJob { + cloud: CloudStorage::global_explicit_client(), + active_namespace_id: active_namespace.to_owned(), + active_critical_key: active_master_key.critical_data_key(), + sources: vec![CleanupSourceNamespace { + namespace_id: source.namespace, + expected_wallets: vec![CleanupExpectedWalletRecord { + record_id: source.record_id, + content_revision_hash: source.revision_hash, + }], + }], + })) + .await + .expect("enqueue cleanup"); + + wait_for_test_condition(Duration::from_secs(1), wait_message, &mut wait_condition).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_deletes_source_namespace_after_active_record_proof() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + active_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &active_master_key, "matching-revision", 1).await, + ); + globals.cloud.set_wallet_files( + active_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("matching-revision".into()), + }, + "cleanup should delete source namespace", + || !globals.cloud.has_namespace(&source_namespace), + ) + .await; + + assert!(!globals.cloud.has_namespace(&source_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_keeps_source_namespace_when_active_record_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let record_id = "missing-record".to_string(); + let active_namespace_list_attempt_count = + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("expected-revision".into()), + }, + "cleanup should inspect active namespace", + || { + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) + > active_namespace_list_attempt_count + }, + ) + .await; + + assert!(globals.cloud.has_namespace(&source_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_keeps_source_namespace_when_active_record_is_undecryptable() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let wrong_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + active_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &wrong_master_key, "expected-revision", 1).await, + ); + globals.cloud.set_wallet_files( + active_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + let active_namespace_list_attempt_count = + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("expected-revision".into()), + }, + "cleanup should inspect active namespace", + || { + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) + > active_namespace_list_attempt_count + }, + ) + .await; + + assert!(globals.cloud.has_namespace(&source_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_keeps_source_namespace_when_active_record_is_unsupported() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + active_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &active_master_key, "expected-revision", 2).await, + ); + globals.cloud.set_wallet_files( + active_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + let active_namespace_list_attempt_count = + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("expected-revision".into()), + }, + "cleanup should inspect active namespace", + || { + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) + > active_namespace_list_attempt_count + }, + ) + .await; + + assert!(globals.cloud.has_namespace(&source_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_keeps_source_namespace_when_active_revision_mismatches() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + active_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &active_master_key, "actual-revision", 1).await, + ); + globals.cloud.set_wallet_files( + active_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + let active_namespace_list_attempt_count = + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("expected-revision".into()), + }, + "cleanup should inspect active namespace", + || { + globals.cloud.list_wallet_files_attempt_count_for_namespace(&active_namespace) + > active_namespace_list_attempt_count + }, + ) + .await; + + assert!(globals.cloud.has_namespace(&source_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn cleanup_keeps_source_namespace_when_delete_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let active_master_key = cove_cspp::master_key::MasterKey::generate(); + let active_namespace = active_master_key.namespace_id(); + let source_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + active_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &active_master_key, "expected-revision", 1).await, + ); + globals.cloud.set_wallet_files( + active_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.set_wallet_files( + source_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.fail_delete_namespace("delete failed"); + let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); + + enqueue_cleanup_for_test( + &manager, + &active_namespace, + &active_master_key, + CleanupTestSource { + namespace: source_namespace.clone(), + record_id, + revision_hash: Some("expected-revision".into()), + }, + "cleanup should attempt source namespace delete", + || globals.cloud.delete_namespace_attempt_count() > delete_attempt_count, + ) + .await; + + assert!(globals.cloud.has_namespace(&source_namespace)); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs b/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs new file mode 100644 index 000000000..52d65ff94 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs @@ -0,0 +1,347 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn connectivity_reconnect_preserves_failed_wallet_upload_health() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + CloudBackupKeychain::global().namespace_id().unwrap(), + metadata.id, + record_id, + PersistedCloudBlobState::Failed(CloudBlobFailedState { + revision_hash: None, + error: "upload failed".into(), + retryable: false, + issue: None, + failed_at: 1, + }), + )) + .unwrap(); + + manager.handle_connectivity_change(ConnectivityStatus::Connected); + + assert!(matches!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(message) if message.contains("upload failed") + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn connectivity_reconnect_reports_clean_health_when_failed_wallet_uploads_are_gone() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + manager.handle_connectivity_change(ConnectivityStatus::Connected); + + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::NoFiles); +} + +#[tokio::test(flavor = "current_thread")] +async fn reconnect_retries_verification_after_offline_failure() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + seed_verifiable_cloud_master_key(globals); + CONNECTIVITY_MANAGER.set_connection_state(false); + + call!(manager.supervisor.start_verification(false)).await.unwrap(); + wait_for_test_condition( + Duration::from_secs(1), + "expected offline verification failure", + || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), + ) + .await; + + CONNECTIVITY_MANAGER.set_connection_state(true); + + wait_for_test_condition( + Duration::from_secs(1), + "expected reconnect to retry and verify backup", + || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn connected_connectivity_failure_retries_detail_refresh_once() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.cloud.fail_next_list_wallet_files_offline("offline"); + + call!(manager.supervisor.start_refresh_detail()).await.unwrap(); + + wait_for_test_condition( + Duration::from_secs(1), + "expected connectivity retry to refresh detail", + || manager.model_snapshot().detail.is_some(), + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn connected_connectivity_failure_retries_verification_once() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + CONNECTIVITY_MANAGER.set_connection_state(true); + seed_verifiable_cloud_master_key(globals); + globals.cloud.fail_next_list_wallet_files_offline("offline"); + + call!(manager.supervisor.start_verification(false)).await.unwrap(); + + wait_for_test_condition( + Duration::from_secs(1), + "expected connected offline failure to retry verification", + || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn unknown_connectivity_does_not_block_verification() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + seed_verifiable_cloud_master_key(globals); + CONNECTIVITY_MANAGER.set_connection_status(ConnectivityStatus::Unknown); + + call!(manager.supervisor.start_verification(false)).await.unwrap(); + + wait_for_test_condition( + Duration::from_secs(1), + "expected unknown connectivity to attempt verification", + || matches!(manager.model_snapshot().verification, VerificationState::Verified(_)), + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn non_connectivity_verification_failure_does_not_retry_on_reconnect() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.cloud.fail_list_wallet_files("list failed"); + + call!(manager.supervisor.start_verification(false)).await.unwrap(); + wait_for_test_condition( + Duration::from_secs(1), + "expected non-connectivity verification failure", + || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), + ) + .await; + + CONNECTIVITY_MANAGER.set_connection_state(false); + manager.handle_connectivity_change(ConnectivityStatus::Disconnected); + CONNECTIVITY_MANAGER.set_connection_state(true); + manager.handle_connectivity_change(ConnectivityStatus::Connected); + + assert_test_condition_stays_true( + Duration::from_millis(150), + "non-connectivity failure should stay failed after reconnect", + || matches!(manager.model_snapshot().verification, VerificationState::Failed(_)), + ) + .await; +} + +#[test] +fn reset_cloud_backup_test_state_clears_state_before_reconnect() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let wallet_id = xpub_only_wallet_metadata().id; + let record_id = cove_cspp::backup_data::wallet_record_id(wallet_id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + CloudBackupKeychain::global().namespace_id().unwrap(), + wallet_id, + record_id, + PersistedCloudBlobState::Failed(CloudBlobFailedState { + revision_hash: None, + error: "upload failed".into(), + retryable: false, + issue: None, + failed_at: 1, + }), + )) + .unwrap(); + manager.apply_enable_state(CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + )); + CONNECTIVITY_MANAGER.set_connection_state(false); + + reset_cloud_backup_test_state_with_hook(&manager, globals, || { + assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); + assert_eq!(manager.model_snapshot().enable_state, CloudBackupEnableState::Idle); + }); + + assert!(CONNECTIVITY_MANAGER.is_connected()); +} + +#[tokio::test(flavor = "current_thread")] +async fn startup_resume_skips_non_retryable_failed_wallet_uploads() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + persist_failed_blob_state(metadata.id.clone(), false); + globals.cloud.fail_wallet_backup_upload_quota_exceeded(); + let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); + + resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; + + assert_test_condition_stays_true( + Duration::from_millis(250), + "startup resume should not retry non-retryable failed uploads", + || globals.cloud.wallet_backup_upload_attempt_count() == initial_attempt_count, + ) + .await; + + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), initial_attempt_count); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), + .. + }) + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; + globals.cloud.clear_wallet_backup_upload_failure(); +} + +#[tokio::test(flavor = "current_thread")] +async fn startup_resume_retries_authorization_failed_wallet_uploads() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + persist_failed_blob_state_with_issue( + metadata.id, + false, + Some(CloudStorageIssue::AuthorizationRequired), + ); + let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); + + resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; + + assert_eq!( + manager.compute_sync_health().await, + CloudSyncHealth::AuthorizationRequired("failed".into()), + ); + wait_for_test_condition( + Duration::from_secs(1), + "startup resume should retry authorization failures", + || globals.cloud.wallet_backup_upload_attempt_count() > initial_attempt_count, + ) + .await; + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cloud_storage_change_retries_authorization_failed_wallet_uploads() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + persist_failed_blob_state_with_issue( + metadata.id, + false, + Some(CloudStorageIssue::AuthorizationRequired), + ); + let initial_attempt_count = globals.cloud.wallet_backup_upload_attempt_count(); + + manager.cloud_storage_did_change(); + + wait_for_test_condition( + Duration::from_secs(3), + "cloud storage change should retry authorization failures", + || globals.cloud.wallet_backup_upload_attempt_count() > initial_attempt_count, + ) + .await; + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn startup_resume_retries_interrupted_uploading_wallets() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + persist_uploading_blob_state(metadata.id, 1); + + resume_wallet_uploads_from_persisted_state_for_test_async(&manager).await; + + wait_for_test_condition( + Duration::from_secs(5), + "startup resume should retry interrupted uploads", + || { + let upload_state_is_pending_or_confirmed = matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + ); + + globals.cloud.wallet_backup_upload_attempt_count() >= 1 + && upload_state_is_pending_or_confirmed + }, + ) + .await; + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/deep_verify.rs b/rust/src/manager/cloud_backup_manager/ops/tests/deep_verify.rs new file mode 100644 index 000000000..ab15277fd --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/deep_verify.rs @@ -0,0 +1,527 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_authenticates_before_loading_wallet_inventory() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + configure_enabled_cloud_backup(&manager, globals, 0); + CONNECTIVITY_MANAGER.set_connection_state(true); + seed_verifiable_cloud_master_key(globals); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + globals.cloud.fail_list_wallet_files("list should not run before passkey auth"); + + let discover_count = globals.passkey.discover_count(); + let list_count = globals.cloud.list_wallet_files_attempt_count(); + + call!(manager.supervisor.start_verification(true)).await.unwrap(); + wait_for_discover_count(globals, discover_count + 1).await; + + assert_eq!(globals.passkey.discover_count(), discover_count + 1); + assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_count); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_corrupted_persisted_state_short_circuits() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + manager + .persist_cloud_backup_state( + &PersistedCloudBackupState::corrupted("decode failed"), + "persist corrupt state for test", + ) + .unwrap(); + globals.cloud.fail_list_namespaces("verify should not read cloud storage"); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let step = manager.prepare_deep_verify_cloud_backup(true).await; + + assert!(matches!( + step, + CloudBackupDeepVerificationStep::Complete(DeepVerificationResult::NotEnabled) + )); + assert_eq!(globals.passkey.discover_count(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_fails_when_auto_sync_upload_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.fail_wallet_backup_upload("upload failed"); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::Failed(DeepVerificationFailure::Retry { + message, detail, .. + }) => { + assert_eq!( + message, + "failed to auto-sync missing wallet backups: cloud storage error: upload failed: upload failed" + ); + let detail = detail.expect("expected detail on retry failure"); + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + } + other => panic!("expected retry failure, got {other:?}"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_persists_partial_auto_sync_upload_before_later_wallet_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let first_wallet = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + globals.cloud.fail_wallet_backup_upload_after_successes(1, "upload failed"); + + let mut second_wallet = WalletMetadata::preview_new(); + second_wallet.wallet_type = WalletType::WatchOnly; + Database::global() + .wallets() + .save_all_wallets( + first_wallet.network, + first_wallet.wallet_mode, + vec![first_wallet.clone(), second_wallet], + ) + .unwrap(); + + let result = deep_verify_for_test(&manager, true).await; + + let record_id = wallet_record_id(first_wallet.id.as_ref()); + match result { + DeepVerificationResult::Failed(DeepVerificationFailure::Retry { message, .. }) => { + assert!(message.contains("upload failed"), "{message}"); + } + other => panic!("expected retry failure, got {other:?}"), + } + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_awaits_upload_confirmation_when_relist_still_misses_uploaded_wallet() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::AwaitingUploadConfirmation(report) => { + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + } + other => panic!("expected awaiting upload confirmation, got {other:?}"), + } + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn manual_verification_clears_interactive_state_when_awaiting_upload_confirmation() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + call!(manager.supervisor.start_verification(true)).await.unwrap(); + wait_for_test_condition( + Duration::from_secs(2), + "verification awaits upload confirmation", + || { + let state = manager.model_snapshot(); + manager.pending_verification_completion().is_some() + && state.pending_upload_verification == PendingUploadVerificationState::Confirming + }, + ) + .await; + + let state = manager.model_snapshot(); + assert!(matches!(state.verification, VerificationState::Idle)); + assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); + assert!(manager.pending_verification_completion().is_some()); + + let detail = state.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); +} + +#[tokio::test(flavor = "current_thread")] +async fn manual_verification_repairs_missing_master_key_wrapper_through_supervisor() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + call!(manager.supervisor.start_verification(true)).await.unwrap(); + + wait_for_test_condition(Duration::from_secs(8), "verification wrapper repair finishes", || { + manager.projected_exclusive_operation().is_none() + && matches!( + manager.model_snapshot().verification, + VerificationState::Verified(DeepVerificationReport { + master_key_wrapper_repaired: true, + .. + }) + ) + }) + .await; + + assert!(globals.cloud.has_master_key_backup(&namespace)); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&cspp_master_key_record_id()).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn manual_verification_loads_wallet_inventory_before_wrapper_repair() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.fail_list_wallet_files("list failed"); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + call!(manager.supervisor.start_verification(true)).await.unwrap(); + + wait_for_test_condition( + Duration::from_secs(8), + "verification fails before wrapper repair", + || { + matches!( + manager.model_snapshot().verification, + VerificationState::Failed(DeepVerificationFailure::Retry { .. }) + ) + }, + ) + .await; + + match manager.model_snapshot().verification { + VerificationState::Failed(DeepVerificationFailure::Retry { message, .. }) => { + assert!(message.contains("failed to list wallet backups"), "{message}"); + } + other => panic!("expected retry failure, got {other:?}"), + } + assert_eq!(globals.passkey.create_count(), 0); + assert!(!globals.cloud.has_master_key_backup(&namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_repairs_stale_local_master_key_before_recreate_manifest() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let stale_master_key = cove_cspp::master_key::MasterKey::generate(); + let remote_master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = remote_master_key.namespace_id(); + let prf_key = [7u8; 32]; + let prf_salt = [9u8; 32]; + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&remote_master_key, &prf_key, &prf_salt) + .unwrap(); + + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace.clone(), + CloudStorageError::NotFound(namespace.clone()), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let keychain = Keychain::global().clone(); + CloudBackupKeychain::new(keychain.clone()).save_namespace_id(&namespace).unwrap(); + let cspp = cove_cspp::Cspp::new(keychain); + cspp.save_master_key(&stale_master_key).unwrap(); + manager + .persist_cloud_backup_state( + &persisted_enabled_cloud_backup_state(Some(0)), + "set cloud backup enabled for test", + ) + .unwrap(); + manager.sync_persisted_state(); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!( + result, + DeepVerificationResult::Failed(DeepVerificationFailure::RecreateManifest { .. }) + )); + let repaired = cspp.load_master_key_from_store().unwrap().unwrap(); + assert_eq!(repaired.as_bytes(), remote_master_key.as_bytes()); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_reads_each_wallet_backup_once() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + configure_enabled_cloud_backup(&manager, globals, 1); + seed_verifiable_cloud_master_key(globals); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) + .load_master_key_from_store() + .unwrap() + .unwrap(); + let metadata = xpub_only_wallet_metadata(); + let record_id = wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + + let prepared = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &metadata, + metadata.wallet_mode, + ) + .await + .unwrap(); + + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, &prepared.revision_hash, 1).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let downloads_before = globals.cloud.wallet_backup_download_attempt_count(); + + let step = manager.prepare_deep_verify_cloud_backup(true).await; + + assert_eq!(globals.cloud.wallet_backup_download_attempt_count() - downloads_before, 1); + + call!(manager.supervisor.complete_verification( + None, + step, + DeepVerificationContinuation::Manual { + force_discoverable: true, + attempt: VerificationAttempt::Initial, + } + )) + .await + .unwrap(); + wait_for_test_condition(Duration::from_secs(8), "deep verification completes", || { + matches!(manager.model_snapshot().verification, VerificationState::Verified(_)) + }) + .await; + + let result = match manager.model_snapshot().verification { + VerificationState::Verified(report) => DeepVerificationResult::Verified(report), + other => panic!("expected verified result, got {other:?}"), + }; + + match result { + DeepVerificationResult::Verified(report) => { + assert_eq!(report.wallets_verified, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 0); + + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + } + other => panic!("expected verified result, got {other:?}"), + } + + assert_eq!(globals.cloud.wallet_backup_download_attempt_count() - downloads_before, 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_preserves_unsupported_remote_wallet_backups() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let keychain = Keychain::global(); + let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); + let master_key = + cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::Verified(report) => { + assert_eq!(report.wallets_verified, 0); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 1); + } + other => panic!("expected verified result, got {other:?}"), + } + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert!(manager.pending_verification_completion().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_retries_when_remote_wallet_truth_is_unknown() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); + globals.cloud.set_wallet_backup(namespace, record_id.clone(), b"{".to_vec()); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::Failed(DeepVerificationFailure::Retry { + message, detail, .. + }) => { + assert_eq!(message, "failed to refresh remote wallet truth for some wallets"); + + let detail = detail.expect("expected verification detail"); + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + assert_eq!( + detail.needs_sync[0].sync_status, + CloudBackupWalletStatus::RemoteStateUnknown + ); + } + other => panic!("expected retry failure, got {other:?}"), + } + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_succeeds_after_auto_sync_relist_confirms_wallet() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::Verified(report) => { + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!( + detail.up_to_date[0].record_id, + cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()) + ); + } + other => panic!("expected verified result, got {other:?}"), + } + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Confirmed(_), .. }) + )); + assert!(!manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn deep_verify_awaits_upload_confirmation_when_remote_revision_is_stale() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) + .load_master_key_from_store() + .unwrap() + .unwrap(); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + globals.cloud.set_wallet_backup_download_override( + namespace, + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "stale-revision", 1).await, + ); + + let result = deep_verify_for_test(&manager, true).await; + + match result { + DeepVerificationResult::AwaitingUploadConfirmation(report) => { + let detail = report.detail.expect("expected verification detail"); + assert!(detail.up_to_date.is_empty()); + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + } + other => panic!("expected awaiting upload confirmation, got {other:?}"), + } + + assert!(manager.pending_verification_completion().is_some()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_), + .. + }) + )); + assert!(manager.has_pending_cloud_upload_verification()); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/disable.rs b/rust/src/manager/cloud_backup_manager/ops/tests/disable.rs new file mode 100644 index 000000000..c58389787 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/disable.rs @@ -0,0 +1,549 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_deletes_active_namespace_and_clears_local_cloud_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::master_key_wrapper( + namespace.clone(), + PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), + )) + .unwrap(); + + run_disable_cloud_backup(&manager).await; + + assert!(!globals.cloud.has_namespace(&namespace)); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); + assert!(CloudBackupKeychain::global().namespace_id().is_none()); + assert!( + globals.cloud.deleted_namespace_policies().contains(&CloudAccessPolicy::ConsentAllowed) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_keeps_disabling_state_when_local_cleanup_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.keychain.fail_delete_at(1); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + + assert!(error.contains("clear cloud backup local keychain state"), "{error}"); + assert!(!globals.cloud.has_namespace(&namespace)); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_some()); + assert!( + disabling + .last_error + .as_deref() + .is_some_and(|message| message.contains("clear cloud backup local keychain state")) + ); + assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); + + run_disable_cloud_backup(&manager).await; + + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert!(current_disable_generation().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_blocks_cloud_only_wallets_without_deleting_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + + assert!(error.contains("cloud-only wallets"), "{error}"); + assert!(globals.cloud.has_namespace(&namespace)); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_none()); + assert!(current_disable_generation().is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_uses_unique_generation_for_each_attempt() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); + + run_disable_cloud_backup(&manager).await; + let first_generation = current_disable_generation().unwrap(); + run_keep_cloud_backup_enabled(&manager).await; + + run_disable_cloud_backup(&manager).await; + let second_generation = current_disable_generation().unwrap(); + + assert_ne!(first_generation, second_generation); +} + +#[tokio::test(flavor = "current_thread")] +async fn keep_cloud_backup_enabled_clears_rolled_back_disable_failure() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id("cloud-only")]); + + run_disable_cloud_backup(&manager).await; + + assert!(globals.cloud.has_namespace(&namespace)); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_none()); + assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); + let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { + panic!("expected configured cloud backup lifecycle"); + }; + assert!(matches!( + configured.destructive_operation, + CloudBackupDestructiveOperationState::DisableFailed { can_keep_enabled: true, .. } + )); + + run_keep_cloud_backup_enabled(&manager).await; + + let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { + panic!("expected configured cloud backup lifecycle"); + }; + assert_eq!(configured.destructive_operation, CloudBackupDestructiveOperationState::Idle); + assert!(globals.cloud.has_namespace(&namespace)); + assert!(current_disable_generation().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn keep_cloud_backup_enabled_resumes_dirty_wallet_marked_during_disable_fence() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = wallet_record_id(metadata.id.as_ref()); + let PersistedCloudBackupState::Configured(previous_configured) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected configured cloud backup state"); + }; + Database::global() + .cloud_backup_state + .set(&PersistedCloudBackupState::Disabling(PersistedDisablingCloudBackup { + previous_configured, + namespace_id: namespace.clone(), + disable_generation: 42, + started_at: 100, + delete_started_at: None, + last_error: None, + retry_after: None, + })) + .unwrap(); + + manager.handle_wallet_backup_change(metadata.id.clone()); + + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 0); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); + + run_keep_cloud_backup_enabled(&manager).await; + + wait_for_test_condition( + Duration::from_secs(5), + "dirty wallet marked during disable fence uploads after Keep Enabled", + || { + let upload_attempted = globals.cloud.wallet_backup_upload_attempt_count() > 0; + let uploaded_or_confirmed = matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + ); + + upload_attempted && uploaded_or_confirmed + }, + ) + .await; + assert!(matches!( + Database::global().cloud_backup_state.get().unwrap(), + PersistedCloudBackupState::Configured(_) + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn keep_cloud_backup_enabled_preserves_configured_runtime_status() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + Database::global() + .cloud_backup_state + .set(&persisted_passkey_missing_cloud_backup_state(Some(3))) + .unwrap(); + manager.reconcile_runtime_status(CloudBackupStatus::Enabled); + manager.apply_disable_outcome(CloudBackupDisableOutcome::Failed { + message: "disable failed".into(), + can_keep_enabled: true, + }); + + manager.finish_keep_cloud_backup_enabled(); + + assert_eq!(manager.current_status(), CloudBackupStatus::PasskeyMissing); + let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { + panic!("expected configured cloud backup lifecycle"); + }; + assert_eq!(configured.destructive_operation, CloudBackupDestructiveOperationState::Idle); +} + +#[tokio::test(flavor = "current_thread")] +async fn keep_cloud_backup_enabled_ignores_stale_disabling_generation() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let PersistedCloudBackupState::Configured(previous_configured) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected configured state"); + }; + + let stale = PersistedDisablingCloudBackup { + previous_configured: previous_configured.clone(), + namespace_id: namespace_id.clone(), + disable_generation: 10, + started_at: 100, + delete_started_at: None, + last_error: None, + retry_after: None, + }; + let current = PersistedDisablingCloudBackup { disable_generation: 11, ..stale.clone() }; + Database::global() + .cloud_backup_state + .set(&PersistedCloudBackupState::Disabling(current)) + .unwrap(); + + let restored = manager.restore_configured_cloud_backup_after_disable(&stale).unwrap(); + + assert!(!restored); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected disabling state"); + }; + assert_eq!(disabling.disable_generation, 11); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_blocks_other_namespaces_without_deleting_them() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let other_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.set_master_key_backup(other_namespace.clone(), vec![4, 5, 6]); + let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + + assert!(error.contains("other cloud backups"), "{error}"); + assert!(globals.cloud.has_namespace(&namespace)); + assert!(globals.cloud.has_namespace(&other_namespace)); + assert_eq!(globals.cloud.delete_namespace_attempt_count(), delete_attempt_count); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_blocks_active_exclusive_operation_without_persisting_disabling() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + let claim = + CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::RecreateManifest, 1); + manager.project_exclusive_operation_started(claim); + + let error = manager.prepare_disable_cloud_backup().await.unwrap_err(); + + assert!(error.to_string().contains("another cloud backup operation"), "{error}"); + assert!(globals.cloud.has_namespace(&namespace)); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!( + manager.projected_exclusive_operation().map(|claim| claim.operation()), + Some(CloudBackupExclusiveOperation::RecreateManifest) + ); + manager.project_exclusive_operation_finished(claim); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_corrupted_persisted_state_fails_closed() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + manager + .persist_cloud_backup_state( + &PersistedCloudBackupState::corrupted("decode failed"), + "persist corrupt state for test", + ) + .unwrap(); + globals.cloud.fail_list_wallet_files("disable should not inspect cloud wallets"); + let list_wallet_files_attempt_count = globals.cloud.list_wallet_files_attempt_count(); + + let error = manager.prepare_disable_cloud_backup().await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::Internal(message) if message == CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE + )); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Corrupted + ); + assert!(matches!( + manager.current_status(), + CloudBackupStatus::Error(message) if message == CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE + )); + assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_wallet_files_attempt_count); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_delete_failure_keeps_disabling_state_and_keychain() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_delete_namespace("delete failed"); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + + assert!(error.contains("delete failed"), "{error}"); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_some()); + assert!( + disabling.last_error.as_deref().is_some_and(|message| message.contains("delete failed")) + ); + assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); + assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_not_found_listing_retries_then_fails_closed() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace.clone(), + CloudStorageError::NotFound("missing".into()), + ); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + + assert!(error.contains("list wallet backups"), "{error}"); + assert_eq!(globals.cloud.list_wallet_files_attempt_count_for_namespace(&namespace), 4); + assert_eq!(globals.cloud.delete_namespace_attempt_count(), 0); + assert!(globals.cloud.has_namespace(&namespace)); + assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); + let PersistedCloudBackupState::Disabling(disabling) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected persisted disabling state"); + }; + assert!(disabling.delete_started_at.is_none()); + assert_eq!(current_disable_generation(), Some(disabling.disable_generation)); +} + +#[tokio::test(flavor = "current_thread")] +async fn disable_cloud_backup_delete_not_found_finishes_cleanup() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_delete_namespace_not_found("already deleted"); + + run_disable_cloud_backup(&manager).await; + + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert!(CloudBackupKeychain::global().namespace_id().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn persisted_disabling_on_restart_resumes_to_disabled() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + let PersistedCloudBackupState::Configured(previous_configured) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected configured cloud backup state"); + }; + + Database::global() + .cloud_backup_state + .set(&PersistedCloudBackupState::Disabling(PersistedDisablingCloudBackup { + previous_configured, + namespace_id: namespace.clone(), + disable_generation: 42, + started_at: 41, + delete_started_at: Some(43), + last_error: Some("interrupted".into()), + retry_after: Some(44), + })) + .unwrap(); + + let restarted_manager = init_manager(); + wait_for_test_condition(Duration::from_secs(2), "restart disable recovery finishes", || { + !globals.cloud.has_namespace(&namespace) + && Database::global().cloud_backup_state.get().unwrap().status() + == PersistedCloudBackupStatus::Disabled + && CloudBackupKeychain::global().namespace_id().is_none() + && restarted_manager.model_snapshot().status == CloudBackupStatus::Disabled + }) + .await; + + assert!(!globals.cloud.has_namespace(&namespace)); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert!(CloudBackupKeychain::global().namespace_id().is_none()); + assert_eq!(restarted_manager.model_snapshot().status, CloudBackupStatus::Disabled); +} + +#[tokio::test(flavor = "current_thread")] +async fn keep_cloud_backup_enabled_after_delete_failure_requires_existing_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_delete_namespace("delete failed"); + + run_disable_cloud_backup(&manager).await; + let error = disable_failure_message(&manager); + assert!(error.contains("delete failed"), "{error}"); + + run_keep_cloud_backup_enabled(&manager).await; + + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(CloudBackupKeychain::global().namespace_id().as_deref(), Some(namespace.as_str())); + assert!(current_disable_generation().is_none()); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/enable.rs b/rust/src/manager/cloud_backup_manager/ops/tests/enable.rs new file mode 100644 index 000000000..a72349360 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/enable.rs @@ -0,0 +1,1000 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn enable_recovery_rolls_back_local_master_key_when_wallet_upload_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + persist_xpub_wallets(vec![xpub_only_wallet_metadata()]); + globals.cloud.fail_wallet_backup_upload("upload failed"); + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + let master_key = cove_cspp::master_key::MasterKey::generate(); + let matched = NamespaceMatch { + namespace_id: master_key.namespace_id(), + master_key, + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + }; + + let preparation = manager + .prepare_enable_recovery(CloudBackupEnableContext::settings_manual(), vec![matched]) + .await + .unwrap(); + manager.save_enable_recovery_master_key(&preparation).unwrap(); + let claim = CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::Enable, 42); + manager.project_exclusive_operation_started(claim); + let writes = operation_write_client_for_test(&manager, claim); + let error = manager.prepare_enable_recovery_completion(preparation, writes).await.unwrap_err(); + manager.project_exclusive_operation_finished(claim); + manager.rollback_enable_recovery_master_key(); + + assert!(matches!(error, CloudBackupError::CloudStorage(_))); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_recovery_rolls_back_local_master_key_when_keychain_save_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + globals.keychain.fail_save_at(3); + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + let master_key = cove_cspp::master_key::MasterKey::generate(); + let matched = NamespaceMatch { + namespace_id: master_key.namespace_id(), + master_key, + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + }; + + let preparation = manager + .prepare_enable_recovery(CloudBackupEnableContext::settings_manual(), vec![matched]) + .await + .unwrap(); + manager.save_enable_recovery_master_key(&preparation).unwrap(); + let claim = CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::Enable, 42); + manager.project_exclusive_operation_started(claim); + let writes = operation_write_client_for_test(&manager, claim); + let completion = manager.prepare_enable_recovery_completion(preparation, writes).await.unwrap(); + manager.project_exclusive_operation_finished(claim); + let error = CloudBackupKeychain::global() + .save_passkey_and_namespace( + &completion.credential_id, + completion.prf_salt, + &completion.namespace_id, + ) + .unwrap_err(); + manager.rollback_enable_recovery_master_key(); + + assert!(matches!(error, CloudBackupKeychainError::Keychain(_))); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn failed_create_new_enable_does_not_persist_passkey_metadata() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + globals.cloud.fail_master_key_upload("boom"); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: vec![7; 32], + credential_id: vec![1, 2, 3], + })); + + let manager = init_manager(); + let error = enable_cloud_backup_create_new(&manager).await.unwrap_err(); + assert!(matches!( + error, + CloudBackupError::Internal(message) + if message.contains("upload master key backup") && message.contains("boom") + )); + + let keychain = Keychain::global(); + assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); + assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); + assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn failed_no_discovery_enable_does_not_persist_passkey_metadata() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Err(PasskeyError::RequestFailed { + operation: PasskeyOperation::AuthenticateAssertion, + reason: PasskeyFailureReason::Unknown { diagnostic_message: "boom".into() }, + })); + + let manager = init_manager(); + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + let keychain = Keychain::global(); + let cspp = cove_cspp::Cspp::new(keychain.clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_some()); + assert!(take_pending_enable_session_for_test(&manager).await.is_some()); + assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); + assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); + assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_create_new_succeeds_with_new_passkey_auth() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_create_new(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + let state = manager.model_snapshot(); + assert!(matches!(state.verification, VerificationState::Idle)); + assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); + assert!(matches!(state.root_prompt, CloudBackupRootPrompt::None)); + assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_some()); + assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_some()); + assert!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).is_some()); + + let discover_count = globals.passkey.discover_count(); + let authenticate_count = globals.passkey.authenticate_count(); + + call!(manager.supervisor.start_enter_detail()).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert_eq!(globals.passkey.discover_count(), discover_count); + assert_eq!(globals.passkey.authenticate_count(), authenticate_count); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_create_new_recovers_from_corrupted_persisted_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + manager + .persist_cloud_backup_state( + &PersistedCloudBackupState::corrupted("decode failed"), + "persist corrupt state for test", + ) + .unwrap(); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_create_new(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Unverified + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn detail_entry_starts_discoverable_verification_without_runtime_authorization() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + manager.clear_runtime_passkey_authorization(); + manager.clear_pending_verification_completion(); + manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); + manager.apply_verification_state(VerificationState::Idle); + Database::global().cloud_blob_sync_states.delete_all().unwrap(); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + globals.cloud.fail_list_wallet_files("list should not run before passkey auth"); + + let discover_count = globals.passkey.discover_count(); + let list_count = globals.cloud.list_wallet_files_attempt_count(); + + call!(manager.supervisor.start_enter_detail()).await.unwrap(); + wait_for_discover_count(globals, discover_count + 1).await; + + assert_eq!(globals.passkey.discover_count(), discover_count + 1); + assert_eq!(globals.cloud.list_wallet_files_attempt_count(), list_count); +} + +#[tokio::test(flavor = "current_thread")] +async fn detail_entry_does_not_restart_rust_owned_verification_states() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + let states = [ + VerificationState::Verifying, + VerificationState::Verified(DeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 0, + wallets_failed: 0, + wallets_unsupported: 0, + detail: None, + }), + VerificationState::PasskeyConfirmed, + ]; + + for verification in states { + configure_enabled_cloud_backup(&manager, globals, 0); + manager.clear_runtime_passkey_authorization(); + manager.clear_pending_verification_completion(); + manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); + manager.apply_verification_state(verification); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let discover_count = globals.passkey.discover_count(); + let authenticate_count = globals.passkey.authenticate_count(); + + call!(manager.supervisor.start_enter_detail()).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert_eq!(globals.passkey.discover_count(), discover_count); + assert_eq!(globals.passkey.authenticate_count(), authenticate_count); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_no_discovery_succeeds_with_new_passkey_auth() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_with_multiple_matching_namespaces_merges_into_largest_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + let third_wallet = xpub_only_wallet_metadata(); + let first_wallet = WalletMetadata { master_fingerprint: None, ..first_wallet }; + let second_wallet = WalletMetadata { master_fingerprint: None, ..second_wallet }; + let third_wallet = WalletMetadata { master_fingerprint: None, ..third_wallet }; + let sample_xpub_from_entropy = |metadata: &WalletMetadata, byte| { + let entropy = [byte; 16]; + let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); + + crate::mnemonic::MnemonicExt::xpub(&mnemonic, metadata.network.into()).to_string() + }; + Keychain::global() + .save_wallet_xpub( + &first_wallet.id, + sample_xpub_from_entropy(&first_wallet, 1).parse().unwrap(), + ) + .unwrap(); + Keychain::global() + .save_wallet_xpub( + &second_wallet.id, + sample_xpub_from_entropy(&second_wallet, 2).parse().unwrap(), + ) + .unwrap(); + Keychain::global() + .save_wallet_xpub( + &third_wallet.id, + sample_xpub_from_entropy(&third_wallet, 3).parse().unwrap(), + ) + .unwrap(); + + let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); + let third_record_id = cove_cspp::backup_data::wallet_record_id(third_wallet.id.as_ref()); + let first_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &first_wallet, + first_wallet.wallet_mode, + ) + .await + .unwrap() + .revision_hash; + let second_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &second_wallet, + second_wallet.wallet_mode, + ) + .await + .unwrap() + .revision_hash; + let third_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &third_wallet, + third_wallet.wallet_mode, + ) + .await + .unwrap() + .revision_hash; + globals.cloud.set_wallet_backup( + first_namespace.clone(), + first_record_id.clone(), + encrypted_wallet_backup_bytes(&first_wallet, &first_master_key, &first_revision, 1).await, + ); + globals.cloud.set_wallet_backup( + second_namespace.clone(), + second_record_id.clone(), + encrypted_wallet_backup_bytes(&second_wallet, &second_master_key, &second_revision, 1) + .await, + ); + globals.cloud.set_wallet_backup( + second_namespace.clone(), + third_record_id.clone(), + encrypted_wallet_backup_bytes(&third_wallet, &second_master_key, &third_revision, 1).await, + ); + globals.cloud.set_wallet_files( + first_namespace.clone(), + vec![wallet_filename_from_record_id(&first_record_id)], + ); + globals.cloud.set_wallet_files( + second_namespace.clone(), + vec![ + wallet_filename_from_record_id(&second_record_id), + wallet_filename_from_record_id(&third_record_id), + ], + ); + + enable_cloud_backup_create_new(&manager).await.unwrap(); + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(second_namespace.clone())); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(3)); + assert!(globals.cloud.has_namespace(&second_namespace)); + wait_for_test_condition( + Duration::from_secs(1), + "merged source namespace should be deleted after proof", + || !globals.cloud.has_namespace(&first_namespace), + ) + .await; + + let active_records = + CloudStorage::global_explicit_client().list_wallet_backups(second_namespace).await.unwrap(); + assert!(active_records.contains(&first_record_id)); + assert!(active_records.contains(&second_record_id)); + assert!(active_records.contains(&third_record_id)); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_recovery_fails_closed_when_matched_wallet_listing_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let prf_key = [7u8; 32]; + let empty_master_key = cove_cspp::master_key::MasterKey::generate(); + let wallet_master_key = cove_cspp::master_key::MasterKey::generate(); + let empty_namespace = empty_master_key.namespace_id(); + let wallet_namespace = wallet_master_key.namespace_id(); + let empty_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&empty_master_key, &prf_key, &[9; 32]) + .unwrap(); + let wallet_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&wallet_master_key, &prf_key, &[8; 32]) + .unwrap(); + + globals.cloud.set_master_key_backup( + empty_namespace.clone(), + serde_json::to_vec(&empty_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + wallet_namespace.clone(), + serde_json::to_vec(&wallet_encrypted).unwrap(), + ); + globals.cloud.fail_list_wallet_files_for_namespace( + empty_namespace.clone(), + CloudStorageError::NotFound("wallet files missing".into()), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let wallet = xpub_only_wallet_metadata(); + let wallet = WalletMetadata { master_fingerprint: None, ..wallet }; + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + let revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &wallet, + wallet.wallet_mode, + ) + .await + .unwrap() + .revision_hash; + globals.cloud.set_wallet_backup( + wallet_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &wallet_master_key, &revision, 1).await, + ); + globals.cloud.set_wallet_files( + wallet_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + + let error = enable_cloud_backup_create_new(&manager).await.unwrap_err(); + + assert!(error.to_string().contains("wallet files missing"), "{error}"); + assert_eq!(CloudBackupKeychain::global().namespace_id(), None); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert!(globals.cloud.has_namespace(&empty_namespace)); + assert!(globals.cloud.has_namespace(&wallet_namespace)); +} + +#[test] +fn clear_in_process_state_for_local_reset_clears_enable_state() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + manager.apply_enable_state(CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + )); + + manager.clear_in_process_state_for_local_reset(); + + assert_eq!(manager.model_snapshot().enable_state, CloudBackupEnableState::Idle); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_preserves_awaiting_force_new_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + let existing_master_key = cove_cspp::master_key::MasterKey::generate(); + let existing_namespace = existing_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); + globals + .cloud + .set_master_key_backup(existing_namespace, serde_json::to_vec(&encrypted_master).unwrap()); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let expected_namespace = master_key.namespace_id(); + let expected_credential_id = vec![1, 2, 3]; + replace_pending_enable_session_for_test( + &manager, + pending_enable_awaiting_confirmation( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: expected_credential_id.clone(), + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + enable_cloud_backup_create_new(&manager).await.unwrap(); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); + assert_eq!(pending_master_key.namespace_id(), expected_namespace); + assert_eq!(pending_passkey.credential_id, expected_credential_id); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_create_new_preserves_awaiting_force_new_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let expected_namespace = master_key.namespace_id(); + let expected_credential_id = vec![1, 2, 3]; + replace_pending_enable_session_for_test( + &manager, + pending_enable_awaiting_confirmation( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: expected_credential_id.clone(), + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + enable_cloud_backup_create_new(&manager).await.unwrap(); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); + assert_eq!(pending_master_key.namespace_id(), expected_namespace); + assert_eq!(pending_passkey.credential_id, expected_credential_id); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_no_discovery_preserves_awaiting_force_new_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Err(PasskeyError::UserCancelled)); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let expected_namespace = master_key.namespace_id(); + let expected_credential_id = vec![1, 2, 3]; + replace_pending_enable_session_for_test( + &manager, + pending_enable_awaiting_confirmation( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: expected_credential_id.clone(), + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + let create_count = globals.passkey.create_count(); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), create_count); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); + match manager.model_snapshot().root_prompt { + CloudBackupRootPrompt::ExistingBackupFound(context, _) => { + assert_eq!(context, CloudBackupEnableContext::settings_manual()); + } + other => panic!("expected existing backup prompt, got {other:?}"), + } + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + let (pending_master_key, pending_passkey) = pending.into_ready_parts().unwrap(); + assert_eq!(pending_master_key.namespace_id(), expected_namespace); + assert_eq!(pending_passkey.credential_id, expected_credential_id); +} + +#[tokio::test(flavor = "current_thread")] +async fn force_new_after_other_namespace_enter_detail_reuses_runtime_authorization() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let existing_master_key = cove_cspp::master_key::MasterKey::generate(); + let existing_namespace = existing_master_key.namespace_id(); + let encrypted_existing_master = + cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); + globals.cloud.set_master_key_backup( + existing_namespace, + serde_json::to_vec(&encrypted_existing_master).unwrap(), + ); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 0); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!(globals.passkey.discover_count(), 0); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); + match manager.model_snapshot().root_prompt { + CloudBackupRootPrompt::ExistingBackupFound(context, _) => { + assert_eq!(context, CloudBackupEnableContext::settings_manual()); + } + other => panic!("expected existing backup prompt, got {other:?}"), + } + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 0); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!(globals.passkey.discover_count(), 0); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + + enable_cloud_backup_force_new(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + let create_count = globals.passkey.create_count(); + let authenticate_count = globals.passkey.authenticate_count(); + let discover_count = globals.passkey.discover_count(); + + call!(manager.supervisor.start_enter_detail()).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert_eq!(globals.passkey.create_count(), create_count); + assert_eq!(globals.passkey.authenticate_count(), authenticate_count); + assert_eq!(globals.passkey.discover_count(), discover_count); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn force_new_after_existing_backup_prompt_registers_without_discovery() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let existing_master_key = cove_cspp::master_key::MasterKey::generate(); + let existing_namespace = existing_master_key.namespace_id(); + let encrypted_existing_master = + cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); + globals.cloud.set_master_key_backup( + existing_namespace, + serde_json::to_vec(&encrypted_existing_master).unwrap(), + ); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 0); + assert_eq!(globals.passkey.discover_count(), 0); + assert!(matches!( + manager.model_snapshot().root_prompt, + CloudBackupRootPrompt::ExistingBackupFound(_, _) + )); + + enable_cloud_backup_force_new(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.discover_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn existing_backup_prompt_preserves_onboarding_enable_context() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let existing_master_key = cove_cspp::master_key::MasterKey::generate(); + let existing_namespace = existing_master_key.namespace_id(); + let encrypted_existing_master = + cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_wallet_files(existing_namespace.clone(), vec!["wallet-1.json".into()]); + globals.cloud.set_master_key_backup( + existing_namespace, + serde_json::to_vec(&encrypted_existing_master).unwrap(), + ); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + let context = CloudBackupEnableContext { + saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, + verification_source: CloudBackupVerificationSource::Onboarding, + }; + enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 0); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!(globals.passkey.discover_count(), 0); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + + match manager.model_snapshot().root_prompt { + CloudBackupRootPrompt::ExistingBackupFound(prompt_context, _) => { + assert_eq!(prompt_context, context); + } + other => panic!("expected existing backup prompt, got {other:?}"), + } + + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + enable_cloud_backup_force_new_with_context(&manager, context).await.unwrap(); + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + assert_eq!(globals.passkey.authenticate_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn existing_passkey_onboarding_recovery_completes_verification() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let prf_key = [7u8; 32]; + let existing_master_key = cove_cspp::master_key::MasterKey::generate(); + let existing_namespace = existing_master_key.namespace_id(); + let encrypted_existing_master = + cove_cspp::master_key_crypto::encrypt_master_key(&existing_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + existing_namespace, + serde_json::to_vec(&encrypted_existing_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let context = CloudBackupEnableContext { + saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, + verification_source: CloudBackupVerificationSource::Onboarding, + }; + enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); + + assert!(matches!( + manager.model_snapshot().root_prompt, + CloudBackupRootPrompt::ExistingBackupFound(_, _) + )); + + manager.dispatch(CloudBackupManagerAction::AcceptEnablePrompt( + CloudBackupEnablePromptChoice::UseExisting, + )); + + wait_for_test_condition( + Duration::from_secs(2), + "existing passkey onboarding recovery should verify", + || { + let snapshot = manager.model_snapshot(); + manager.current_status() == CloudBackupStatus::Enabled + && matches!(snapshot.verification, VerificationState::Verified(_)) + }, + ) + .await; + + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Idle + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn detail_entry_after_restart_without_active_authorization_prompts_normally() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + + let restarted_manager = init_manager(); + restarted_manager.sync_persisted_state(); + restarted_manager.clear_pending_verification_completion(); + restarted_manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); + restarted_manager.apply_verification_state(VerificationState::Idle); + Database::global().cloud_blob_sync_states.delete_all().unwrap(); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let discover_count = globals.passkey.discover_count(); + + call!(restarted_manager.supervisor.start_enter_detail()).await.unwrap(); + wait_for_discover_count(globals, discover_count + 1).await; + + assert_eq!(globals.passkey.discover_count(), discover_count + 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn enable_force_new_consumes_staged_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + replace_pending_enable_session_for_test( + &manager, + pending_enable_awaiting_confirmation( + cove_cspp::master_key::MasterKey::generate(), + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + enable_cloud_backup_force_new(&manager).await.unwrap(); + + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_enable_create_new_rolls_back_new_local_master_key() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + enable_cloud_backup_create_new(&manager).await.unwrap(); + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); + assert!(matches!( + manager.model_snapshot().root_prompt, + CloudBackupRootPrompt::PasskeyChoice(CloudBackupPasskeyChoiceIntent::Enable(_, _)) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_enable_no_discovery_rolls_back_new_local_master_key() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + globals.passkey.set_create_result(Err(PasskeyError::UserCancelled)); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabling); + assert!(matches!( + manager.model_snapshot().root_prompt, + CloudBackupRootPrompt::PasskeyChoice(CloudBackupPasskeyChoiceIntent::Enable(_, _)) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_passkey_restore_does_not_fall_back_to_local_master_key() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let local_master_key = cove_cspp::master_key::MasterKey::generate(); + let local_namespace_id = local_master_key.namespace_id(); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&local_master_key).unwrap(); + globals.cloud.set_wallet_files(local_namespace_id.clone(), vec!["wallet-test.json".into()]); + + let remote_master_key = cove_cspp::master_key::MasterKey::generate(); + let remote_namespace_id = remote_master_key.namespace_id(); + let remote_prf_key = [7u8; 32]; + let remote_prf_salt = [9u8; 32]; + let encrypted_master = cove_cspp::master_key_crypto::encrypt_master_key( + &remote_master_key, + &remote_prf_key, + &remote_prf_salt, + ) + .unwrap(); + globals.cloud.set_master_key_backup( + remote_namespace_id.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.cloud.set_wallet_files(remote_namespace_id, vec!["wallet-remote.json".into()]); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(matches!(error, CloudBackupError::PasskeyDiscoveryCancelled)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), None); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/enable_session.rs b/rust/src/manager/cloud_backup_manager/ops/tests/enable_session.rs new file mode 100644 index 000000000..8b4eb2633 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/enable_session.rs @@ -0,0 +1,500 @@ +use super::*; + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn registered_passkey_stages_confirmation_without_automatic_auth() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!(CloudBackupKeychain::global().load_credential_id(), None); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn registered_passkey_confirmation_session_prevents_duplicate_create() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); + let (_, pending_passkey) = pending.into_staged_parts().unwrap(); + assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn normal_enable_preserves_registered_passkey_confirmation_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + globals.passkey.set_create_result(Ok(vec![4, 5, 6])); + enable_cloud_backup_create_new(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); + let (_, pending_passkey) = pending.into_staged_parts().unwrap(); + assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn reinitialize_preserves_registered_passkey_confirmation_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + globals.passkey.set_create_result(Ok(vec![4, 5, 6])); + run_reinitialize_backup_operation(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); + let (_, pending_passkey) = pending.into_staged_parts().unwrap(); + assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn force_new_preserves_registered_passkey_confirmation_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + globals.passkey.set_create_result(Ok(vec![4, 5, 6])); + enable_cloud_backup_force_new(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); + let (_, pending_passkey) = pending.into_staged_parts().unwrap(); + assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn registered_passkey_stages_confirmation_without_duplicate_create() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 0); + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); + let (_, pending_passkey) = pending.into_staged_parts().unwrap(); + assert_eq!(pending_passkey.credential_id, vec![1, 2, 3]); +} + +#[tokio::test(flavor = "current_thread")] +async fn confirm_saved_passkey_reuses_original_credential_id() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 1); + assert_eq!(globals.passkey.authenticated_credential_ids(), vec![vec![1, 2, 3]]); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn automatic_saved_passkey_confirmation_retries_until_available() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Err(PasskeyError::NoCredentialFound)); + globals.passkey.push_authenticate_result(Err(PasskeyError::NoCredentialFound)); + globals.passkey.push_authenticate_result(Ok(vec![7; 32])); + + let context = CloudBackupEnableContext { + saved_passkey_confirmation: SavedPasskeyConfirmationMode::Automatic, + verification_source: CloudBackupVerificationSource::Onboarding, + }; + enable_cloud_backup_no_discovery_with_context(&manager, context).await.unwrap(); + + assert_eq!(globals.passkey.create_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 3); + assert_eq!( + globals.passkey.authenticated_credential_ids(), + vec![vec![1, 2, 3], vec![1, 2, 3], vec![1, 2, 3]] + ); + assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn duplicate_confirm_saved_passkey_dispatches_are_ignored_while_confirming() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + let master_key = cove_cspp::master_key::MasterKey::generate(); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + replace_pending_enable_session_for_test( + &manager, + PendingEnableSession::awaiting_saved_passkey_confirmation( + zeroize::Zeroizing::new(master_key), + zeroize::Zeroizing::new(StagedPrfKey { + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }), + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + manager.dispatch(CloudBackupManagerAction::ConfirmSavedPasskey); + manager.dispatch(CloudBackupManagerAction::ConfirmSavedPasskey); + + wait_for_test_condition( + Duration::from_secs(1), + "saved passkey confirmation should enable cloud backup", + || manager.current_status() == CloudBackupStatus::Enabled, + ) + .await; + + assert_eq!(globals.passkey.authenticate_count(), 1); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + + let authenticate_count = globals.passkey.authenticate_count(); + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert_eq!(globals.passkey.authenticate_count(), authenticate_count); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Idle + ); + assert!(matches!(manager.model_snapshot().verification, VerificationState::Verified(_))); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_saved_passkey_confirmation_preserves_pending_session() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + + globals.passkey.set_authenticate_result(Err(PasskeyError::UserCancelled)); + confirm_saved_passkey_session(&manager).await; + + assert_eq!( + manager.model_snapshot().enable_state, + CloudBackupEnableState::AwaitingSavedPasskeyConfirmation( + SavedPasskeyConfirmationMode::Manual, + ) + ); + let pending = take_pending_enable_session_for_test(&manager).await.unwrap(); + assert!(matches!(pending, PendingEnableSession::AwaitingSavedPasskeyConfirmation(_))); +} + +#[tokio::test(flavor = "current_thread")] +async fn unsupported_provider_saved_passkey_confirmation_fails_without_retry() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + let master_key = cove_cspp::master_key::MasterKey::generate(); + replace_pending_enable_session_for_test( + &manager, + PendingEnableSession::awaiting_saved_passkey_confirmation( + zeroize::Zeroizing::new(master_key), + zeroize::Zeroizing::new(StagedPrfKey { + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }), + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + globals.passkey.set_authenticate_result(Err(PasskeyError::PrfUnsupportedProvider)); + confirm_saved_passkey_session(&manager).await; + + assert_eq!(globals.passkey.authenticate_count(), 1); + assert_eq!(manager.current_status(), CloudBackupStatus::UnsupportedPasskeyProvider); + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn discard_pending_enable_clears_pending_session_and_local_master_key() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + cspp.save_master_key(&master_key).unwrap(); + replace_pending_enable_session_for_test( + &manager, + pending_enable_awaiting_confirmation( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + manager.discard_pending_enable_cloud_backup(); + + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn discard_pending_enable_retry_upload_deletes_remote_master_key() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + cspp.save_master_key(&master_key).unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + replace_pending_enable_session_for_test( + &manager, + PendingEnableSession::retry_upload( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + manager.discard_pending_enable_cloud_backup(); + + wait_for_test_condition( + Duration::from_secs(1), + "remote master key backup should be deleted", + || !globals.cloud.has_master_key_backup(&namespace), + ) + .await; + + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn discard_pending_enable_retry_upload_treats_missing_remote_master_key_as_deleted() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + cspp.save_master_key(&master_key).unwrap(); + globals.cloud.fail_delete_wallet_backup_not_found("already deleted"); + replace_pending_enable_session_for_test( + &manager, + PendingEnableSession::retry_upload( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + manager.discard_pending_enable_cloud_backup(); + + wait_for_test_condition( + Duration::from_secs(1), + "pending enable session should be discarded after missing remote cleanup", + || cspp.load_master_key_from_store().unwrap().is_none(), + ) + .await; + + assert!(take_pending_enable_session_for_test(&manager).await.is_none()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn discard_pending_enable_retry_upload_keeps_pending_state_when_remote_cleanup_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + cspp.save_master_key(&master_key).unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_delete_wallet_backup("delete failed"); + replace_pending_enable_session_for_test( + &manager, + PendingEnableSession::retry_upload( + master_key, + UnpersistedPrfKey { + prf_key: [7; 32], + prf_salt: [9; 32], + credential_id: vec![1, 2, 3], + provider_hint: None, + }, + CloudBackupEnableContext::settings_manual(), + ), + ) + .await; + + manager.discard_pending_enable_cloud_backup(); + + wait_for_test_condition( + Duration::from_secs(1), + "discard pending enable cleanup failure should surface", + || matches!(manager.state().lifecycle, CloudBackupLifecycle::Failed(_)), + ) + .await; + + assert!(globals.cloud.has_master_key_backup(&namespace)); + assert!(cspp.load_master_key_from_store().unwrap().is_some()); + assert!(take_pending_enable_session_for_test(&manager).await.is_some()); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/master_key.rs b/rust/src/manager/cloud_backup_manager/ops/tests/master_key.rs new file mode 100644 index 000000000..01933bdda --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/master_key.rs @@ -0,0 +1,145 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn cloud_action_uses_existing_master_key_without_recovery() { + cove_tokio::init(); + let store = Arc::new(MockStore::default()); + let cspp = cove_cspp::Cspp::new(MockStoreHandle(store)); + let expected = cove_cspp::master_key::MasterKey::generate(); + let namespace = expected.namespace_id(); + cspp.save_master_key(&expected).unwrap(); + + let recovered = load_master_key_for_cloud_action(&cspp, &namespace, || async { + Err(CloudBackupError::RecoveryRequired("unexpected".into())) + }) + .await + .unwrap(); + + assert_eq!(recovered.as_bytes(), expected.as_bytes()); +} + +#[tokio::test(flavor = "current_thread")] +async fn cloud_action_does_not_create_master_key_when_missing() { + cove_tokio::init(); + let store = Arc::new(MockStore::default()); + let cspp = cove_cspp::Cspp::new(MockStoreHandle(store.clone())); + let namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + + let result = load_master_key_for_cloud_action(&cspp, &namespace, || async { + Err(CloudBackupError::RecoveryRequired("needs recovery".into())) + }) + .await; + + assert!(matches!( + result, + Err(CloudBackupError::RecoveryRequired(message)) if message == "needs recovery" + )); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert_eq!(*store.save_count.lock(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn cloud_action_recovers_when_local_master_key_namespace_mismatches() { + cove_tokio::init(); + let store = Arc::new(MockStore::default()); + let cspp = cove_cspp::Cspp::new(MockStoreHandle(store)); + let stale = cove_cspp::master_key::MasterKey::generate(); + cspp.save_master_key(&stale).unwrap(); + + let expected = cove_cspp::master_key::MasterKey::generate(); + let expected_bytes = *expected.as_bytes(); + let namespace = expected.namespace_id(); + + let recovered = load_master_key_for_cloud_action(&cspp, &namespace, || async move { + Ok(cove_cspp::master_key::MasterKey::from_bytes(expected_bytes)) + }) + .await + .unwrap(); + + assert_eq!(recovered.as_bytes(), expected.as_bytes()); +} + +#[tokio::test(flavor = "current_thread")] +async fn local_master_key_fallback_persists_namespace_id() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let store = Arc::new(MockStore::default()); + let store_handle = MockStoreHandle(store.clone()); + let cspp = cove_cspp::Cspp::new(store_handle.clone()); + let expected = cove_cspp::master_key::MasterKey::generate(); + let namespace_id = expected.namespace_id(); + cspp.save_master_key(&expected).unwrap(); + globals.cloud.set_wallet_files(namespace_id.clone(), vec!["wallet-test.json".into()]); + + let (restored, restored_namespace) = restore_from_local_master_key_fallback( + &CloudStorage::global_explicit_client(), + &store_handle, + &cspp, + ) + .await + .unwrap(); + + assert_eq!(restored.as_bytes(), expected.as_bytes()); + assert_eq!(restored_namespace, namespace_id.clone()); + assert_eq!( + store_handle.get(CSPP_NAMESPACE_ID_KEY.into()).as_deref(), + Some(namespace_id.as_str()) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn local_master_key_fallback_is_unavailable_after_local_cloud_state_clear() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let keychain = Keychain::global(); + let cspp = cove_cspp::Cspp::new(keychain.clone()); + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace_id = master_key.namespace_id(); + cspp.save_master_key(&master_key).unwrap(); + globals.cloud.set_wallet_files(namespace_id, vec!["wallet-test.json".into()]); + + CloudBackupKeychain::global().clear_local_state().unwrap(); + + let fallback = + try_restore_from_local_master_key(&CloudStorage::global_explicit_client(), &cspp) + .await + .unwrap(); + + assert!(fallback.is_none()); +} + +#[test] +fn blocking_cloud_error_keeps_unavailable_storage_errors_visible() { + let error = blocking_cloud_error( + BlockingCloudStep::Enable, + CloudBackupError::CloudStorage(CloudStorageError::NotAvailable( + "iCloud Drive is not available".into(), + )), + ); + + assert!(matches!( + error, + CloudBackupError::CloudStorage(CloudStorageError::NotAvailable(message)) + if message == "iCloud Drive is not available" + )); +} + +#[test] +fn blocking_cloud_error_rewrites_offline_storage_errors_to_step_message() { + let error = blocking_cloud_error( + BlockingCloudStep::Enable, + CloudBackupError::CloudStorage(CloudStorageError::Offline("offline".into())), + ); + + assert!(matches!( + error, + CloudBackupError::Offline(message) + if message == "Reconnect to the internet, then try enabling cloud backup again" + )); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/other_backups.rs b/rust/src/manager/cloud_backup_manager/ops/tests/other_backups.rs new file mode 100644 index 000000000..4e9795f00 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/other_backups.rs @@ -0,0 +1,574 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn fetch_cloud_only_wallets_surfaces_unsupported_versions() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let keychain = Keychain::global(); + let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); + let master_key = + cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let wallets = manager.do_fetch_cloud_only_wallets().await.unwrap(); + + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].record_id, record_id); + assert_eq!(wallets[0].name, UNSUPPORTED_CLOUD_ONLY_WALLET_NAME); + assert_eq!(wallets[0].sync_status, CloudBackupWalletStatus::UnsupportedVersion); + assert_eq!(wallets[0].network, None); + assert_eq!(wallets[0].wallet_mode, None); + assert_eq!(wallets[0].wallet_type, None); + assert_eq!(wallets[0].label_count, None); + assert_eq!(wallets[0].backup_updated_at, None); +} + +#[tokio::test(flavor = "current_thread")] +async fn detail_reports_other_backup_namespaces() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); + globals.cloud.set_wallet_files( + current_namespace, + vec![wallet_filename_from_record_id("current-wallet")], + ); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = cove_cspp::master_key_crypto::encrypt_master_key_with_provider_hint( + &other_master_key, + &[7; 32], + &[9; 32], + Some(cove_cspp::backup_data::PasskeyProviderHint { + aaguid: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4".into(), + registered_platform: cove_cspp::backup_data::PasskeyRegistrationPlatform::Android, + registered_at: 1_777_661_234, + name_suffix: "09IX".into(), + }), + ) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.cloud.set_wallet_files( + other_namespace, + vec![ + wallet_filename_from_record_id("other-wallet-1"), + wallet_filename_from_record_id("other-wallet-2"), + ], + ); + + let Some(CloudBackupDetailResult::Success(detail)) = + manager.refresh_cloud_backup_detail().await + else { + panic!("expected cloud backup detail"); + }; + + let CloudBackupOtherBackupsState::Loaded { summary } = detail.other_backups else { + panic!("expected loaded other backups"); + }; + assert_eq!(summary.namespace_count, 1); + assert_eq!(summary.wallet_count, 2); + assert_eq!(summary.passkey_hints.len(), 1); + assert_eq!(summary.passkey_hints[0].name_suffix, "09IX"); + assert_eq!(summary.passkey_hints[0].provider_name.as_deref(), Some("Google Password Manager")); +} + +#[tokio::test(flavor = "current_thread")] +async fn other_backup_summary_counts_only_wallets_missing_from_local_wallets() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let local_record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata]); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_wallet_files(current_namespace.clone(), Vec::new()); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); + globals.cloud.set_wallet_files( + other_namespace, + vec![ + wallet_filename_from_record_id(&local_record_id), + wallet_filename_from_record_id("missing-local-wallet"), + ], + ); + + let summary = + manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); + assert_eq!(summary.namespace_count, 1); + assert_eq!(summary.wallet_count, 1); + + globals.cloud.set_wallet_files( + current_namespace, + vec![wallet_filename_from_record_id("missing-local-wallet")], + ); + + let summary = + manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); + assert_eq!(summary.namespace_count, 1); + assert_eq!(summary.wallet_count, 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn other_backup_summary_fails_closed_when_wallet_listing_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); + globals.cloud.fail_list_wallet_files_for_namespace( + other_namespace, + CloudStorageError::NotFound("wallet files missing".into()), + ); + + let error = + manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap_err(); + + assert!(error.to_string().contains("wallet files missing"), "{error}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn detail_refresh_marks_other_backups_failed_when_namespace_inspection_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_wallet_files(current_namespace, Vec::new()); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + globals.cloud.set_master_key_backup(other_namespace.clone(), vec![1, 2, 3]); + globals + .cloud + .fail_master_key_download_offline(other_namespace, "offline while inspecting namespace"); + + let Some(CloudBackupDetailResult::Success(detail)) = + manager.refresh_cloud_backup_detail().await + else { + panic!("expected cloud backup detail"); + }; + + let CloudBackupOtherBackupsState::LoadFailed { error } = detail.other_backups else { + panic!("expected failed other backups state"); + }; + assert_eq!( + error, + "offline: Reconnect to the internet, then try refreshing cloud backup details again" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_keeps_current_passkey_metadata() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[9, 8, 7], [6; 32], ¤t_namespace) + .unwrap(); + + let prf_key = [7u8; 32]; + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + other_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, + ); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + + let current_namespace_list_attempt_count = + globals.cloud.list_wallet_files_attempt_count_for_namespace(¤t_namespace); + let report = manager.do_recover_other_backups().await.unwrap(); + + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!( + globals.cloud.list_wallet_files_attempt_count_for_namespace(¤t_namespace), + current_namespace_list_attempt_count + 3, + ); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(!globals.cloud.has_namespace(&other_namespace)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(current_namespace.clone())); + assert_eq!( + globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), + Some(current_namespace.as_str()) + ); + assert_eq!(CloudBackupKeychain::global().load_credential_id(), Some(vec![9, 8, 7])); + assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_some()); + + let summary = + manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); + assert_eq!(summary.namespace_count, 0); + assert_eq!(summary.wallet_count, 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_current_namespace_not_found_fails_closed_without_source_delete() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.fail_list_wallet_files_for_namespace( + current_namespace, + CloudStorageError::NotFound("current wallet files missing".into()), + ); + + let prf_key = [7u8; 32]; + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + other_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, + ); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + let delete_attempt_count = globals.cloud.delete_namespace_attempt_count(); + + let error = manager.do_recover_other_backups().await.unwrap_err(); + + assert!(error.to_string().contains("current wallet files missing"), "{error}"); + assert!(globals.cloud.has_namespace(&other_namespace)); + assert_eq!(globals.cloud.delete_namespace_attempt_count(), delete_attempt_count); +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_keeps_partially_moved_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let prf_key = [7u8; 32]; + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let restored_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&restored_wallet.id, sample_xpub(&restored_wallet).parse().unwrap()) + .unwrap(); + let restored_record_id = cove_cspp::backup_data::wallet_record_id(restored_wallet.id.as_ref()); + let missing_wallet = xpub_only_wallet_metadata(); + let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + other_namespace.clone(), + restored_record_id.clone(), + encrypted_wallet_backup_bytes(&restored_wallet, &other_master_key, "other-revision", 1) + .await, + ); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![ + wallet_filename_from_record_id(&restored_record_id), + wallet_filename_from_record_id(&missing_record_id), + ], + ); + + let report = manager.do_recover_other_backups().await.unwrap(); + + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 1); + assert!(globals.cloud.has_namespace(&other_namespace)); + + let summary = + manager.other_backup_summary(&CloudStorage::global_explicit_client()).await.unwrap(); + assert_eq!(summary.namespace_count, 1); + assert_eq!(summary.wallet_count, 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_keeps_namespace_when_current_upload_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + globals.cloud.fail_wallet_backup_upload("upload failed"); + + let prf_key = [7u8; 32]; + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + other_namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &other_master_key, "other-revision", 1).await, + ); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + + let result = manager.do_recover_other_backups().await; + + assert!(result.is_err()); + assert!(globals.cloud.has_namespace(&other_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_returns_offline_when_wallet_download_is_offline() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let prf_key = [7u8; 32]; + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &prf_key, &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let wallet = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![wallet_filename_from_record_id(&record_id)], + ); + globals.cloud.fail_wallet_backup_download_offline( + other_namespace, + record_id, + "offline while downloading wallet", + ); + + let result = manager.do_recover_other_backups().await; + + match result { + Err(CloudBackupError::Offline(message)) => { + assert_eq!( + message, + "Reconnect to the internet, then try recovering the other cloud backups again" + ); + } + Ok(report) => panic!( + "expected offline error, got report with {} failed wallet(s)", + report.wallets_failed + ), + Err(error) => panic!("expected offline error, got {error:?}"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn recover_other_backups_returns_offline_when_namespace_inspection_is_offline() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals + .cloud + .fail_master_key_download_offline(other_namespace, "offline while inspecting namespace"); + + let result = manager.do_recover_other_backups().await; + + match result { + Err(CloudBackupError::Offline(message)) => { + assert_eq!( + message, + "Reconnect to the internet, then try recovering the other cloud backups again" + ); + } + Ok(report) => panic!( + "expected offline error, got report with {} restored wallet(s)", + report.wallets_restored + ), + Err(error) => panic!("expected offline error, got {error:?}"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn delete_other_backups_removes_only_non_current_namespaces() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); + globals.cloud.set_wallet_files( + current_namespace.clone(), + vec![wallet_filename_from_record_id("current-wallet")], + ); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.cloud.set_wallet_files( + other_namespace.clone(), + vec![wallet_filename_from_record_id("other-wallet")], + ); + + manager.do_delete_other_backups().await.unwrap(); + + assert!(globals.cloud.has_namespace(¤t_namespace)); + assert!(!globals.cloud.has_namespace(&other_namespace)); + assert_eq!(globals.cloud.deleted_namespace_policies(), vec![CloudAccessPolicy::ConsentAllowed]); +} + +#[tokio::test(flavor = "current_thread")] +async fn delete_other_backups_returns_offline_when_namespace_inspection_is_offline() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let current_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_master_key_backup(current_namespace.clone(), vec![1, 2, 3]); + + let other_master_key = cove_cspp::master_key::MasterKey::generate(); + let other_namespace = other_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&other_master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + other_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.cloud.fail_master_key_download_offline( + other_namespace.clone(), + "offline while inspecting namespace", + ); + + let result = manager.do_delete_other_backups().await; + + match result { + Err(CloudBackupError::Offline(message)) => { + assert_eq!( + message, + "Reconnect to the internet, then try deleting the other cloud backups again" + ); + } + Ok(()) => panic!("expected offline error"), + Err(error) => panic!("expected offline error, got {error:?}"), + } + assert!(globals.cloud.has_namespace(&other_namespace)); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs b/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs new file mode 100644 index 000000000..e58886b0f --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs @@ -0,0 +1,514 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_treats_missing_credential_as_no_match() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + globals.passkey.set_discover_result(Err(PasskeyError::NoCredentialFound)); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[namespace]) + .await + .unwrap(); + + assert!(matches!(outcome, NamespaceMatchOutcome::NoMatch)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_treats_user_cancel_as_user_declined() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[namespace]) + .await + .unwrap(); + + assert!(matches!(outcome, NamespaceMatchOutcome::UserDeclined)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_mixed_supported_and_unsupported_versions_returns_no_match() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let supported_master_key = cove_cspp::master_key::MasterKey::generate(); + let supported_namespace = supported_master_key.namespace_id(); + let unsupported_master_key = cove_cspp::master_key::MasterKey::generate(); + let unsupported_namespace = unsupported_master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&supported_master_key, &[7; 32], &[9; 32]) + .unwrap(); + let mut unsupported_master = cove_cspp::master_key_crypto::encrypt_master_key( + &unsupported_master_key, + &[7; 32], + &[9; 32], + ) + .unwrap(); + unsupported_master.version = 2; + + globals.cloud.set_master_key_backup( + supported_namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.cloud.set_master_key_backup( + unsupported_namespace.clone(), + serde_json::to_vec(&unsupported_master).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: vec![8; 32], + credential_id: vec![1, 2, 3], + })); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[supported_namespace, unsupported_namespace]) + .await + .unwrap(); + + assert!(matches!(outcome, NamespaceMatchOutcome::NoMatch)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_discovery_propagates_unsupported_provider() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); + + let result = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[namespace]) + .await; + let error = match result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; + + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_targeted_auth_propagates_unsupported_provider() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &[7; 32], &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &[8; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: vec![1; 32], + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Err(PasskeyError::PrfUnsupportedProvider)); + + let result = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[first_namespace, second_namespace]) + .await; + let error = match result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; + + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_allows_one_credential_to_match_multiple_namespaces() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[first_namespace.clone(), second_namespace.clone()]) + .await + .unwrap(); + + let NamespaceMatchOutcome::Matched(matches) = outcome else { + panic!("expected multiple namespace matches"); + }; + let matched_namespaces = + matches.into_iter().map(|matched| matched.namespace_id).collect::>(); + + assert_eq!(matched_namespaces, vec![first_namespace, second_namespace]); +} + +#[tokio::test(flavor = "current_thread")] +async fn wrapper_repair_discovery_propagates_unsupported_provider() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); + + let acquirer = PasskeyMaterialAcquirer::new(PasskeyAccess::global()); + let discovery_result = acquirer.discover_or_create_for_wrapper_repair().await; + let error = match discovery_result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; + + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); +} + +#[test] +fn save_passkey_rolls_back_on_second_save_failure() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + globals.keychain.set_entries(vec![ + (CSPP_CREDENTIAL_ID_KEY, "old_credential"), + (CSPP_PRF_SALT_KEY, "old_salt"), + ]); + globals.keychain.fail_save_at(2); + + let error = CloudBackupKeychain::global().save_passkey(&[1, 2, 3], [7; 32]).unwrap_err(); + + assert!(matches!( + error, + CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Save) + )); + assert_eq!( + globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), + Some("old_credential") + ); + assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).as_deref(), Some("old_salt")); +} + +#[test] +fn save_passkey_and_namespace_rolls_back_on_third_save_failure() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + globals.keychain.set_entries(vec![ + (CSPP_CREDENTIAL_ID_KEY, "old_credential"), + (CSPP_PRF_SALT_KEY, "old_salt"), + (CSPP_NAMESPACE_ID_KEY, "old_namespace"), + ]); + globals.keychain.fail_save_at(3); + + let error = CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3], [9; 32], "new_namespace") + .unwrap_err(); + + assert!(matches!( + error, + CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Save) + )); + assert_eq!( + globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), + Some("old_credential") + ); + assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).as_deref(), Some("old_salt")); + assert_eq!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), Some("old_namespace")); +} + +#[test] +fn load_credential_id_returns_none_for_invalid_hex_and_decodes_valid_hex() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + globals.keychain.set_entries(vec![(CSPP_CREDENTIAL_ID_KEY, "not-hex")]); + + assert!(CloudBackupKeychain::global().load_credential_id().is_none()); + + let credential_id = vec![1, 2, 3, 254, 255]; + let credential_hex = hex::encode(&credential_id); + globals.keychain.set_entries(vec![(CSPP_CREDENTIAL_ID_KEY, &credential_hex)]); + + assert_eq!(CloudBackupKeychain::global().load_credential_id(), Some(credential_id)); +} + +#[test] +fn clear_passkey_removes_credential_and_salt_only() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + globals.keychain.set_entries(vec![ + (CSPP_CREDENTIAL_ID_KEY, "credential"), + (CSPP_PRF_SALT_KEY, "salt"), + (CSPP_NAMESPACE_ID_KEY, "namespace"), + ]); + + CloudBackupKeychain::global().clear_passkey(); + + assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_none()); + assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_none()); + assert_eq!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), Some("namespace")); +} + +#[test] +fn clear_local_state_treats_empty_keychain_as_success() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + + CloudBackupKeychain::global().clear_local_state().unwrap(); + assert!(CloudBackupKeychain::global().namespace_id().is_none()); +} + +#[test] +fn clear_local_state_removes_master_key_and_passkey_metadata() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + + let keychain = Keychain::global(); + let cloud_keychain = CloudBackupKeychain::global(); + let cspp = cove_cspp::Cspp::new(keychain.clone()); + let master_key = cove_cspp::master_key::MasterKey::generate(); + cspp.save_master_key(&master_key).unwrap(); + cloud_keychain.save_passkey_and_namespace(&[1, 2, 3], [4; 32], "test-namespace").unwrap(); + + assert!(cspp.load_master_key_from_store().unwrap().is_some()); + assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_some()); + assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_some()); + assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_some()); + + cloud_keychain.clear_local_state().unwrap(); + + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); + assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); + assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); +} + +#[test] +fn clear_local_state_attempts_passkey_metadata_after_master_key_delete_failure() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + + let keychain = Keychain::global(); + let cloud_keychain = CloudBackupKeychain::global(); + let cspp = cove_cspp::Cspp::new(keychain.clone()); + let master_key = cove_cspp::master_key::MasterKey::generate(); + cspp.save_master_key(&master_key).unwrap(); + cloud_keychain.save_passkey_and_namespace(&[1, 2, 3], [4; 32], "test-namespace").unwrap(); + + globals.keychain.fail_delete_at(1); + + let error = cloud_keychain.clear_local_state().unwrap_err(); + + assert!(matches!( + error, + CloudBackupKeychainError::Keychain(cove_device::keychain::KeychainError::Delete) + )); + assert!(keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).is_none()); + assert!(keychain.get(CSPP_PRF_SALT_KEY.into()).is_none()); + assert!(keychain.get(CSPP_NAMESPACE_ID_KEY.into()).is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_repair_finalization_keeps_existing_count_when_wallet_refresh_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 2); + + Database::global() + .cloud_backup_state + .set(&persisted_passkey_missing_cloud_backup_state(Some(7))) + .unwrap(); + manager.sync_persisted_state(); + globals.cloud.fail_list_wallet_files("timed out"); + + let finalization = manager.prepare_passkey_repair_finalization().await.unwrap(); + manager.apply_passkey_repair_finalization(finalization).unwrap(); + + let state = Database::global().cloud_backup_state.get().unwrap(); + assert_eq!(state.status(), PersistedCloudBackupStatus::Enabled); + assert_eq!(state.wallet_count(), Some(7)); + assert_eq!(manager.model_snapshot().status, CloudBackupStatus::Enabled); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_repair_finalization_keeps_existing_count_when_wallet_listing_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 2); + + Database::global() + .cloud_backup_state + .set(&persisted_passkey_missing_cloud_backup_state(Some(7))) + .unwrap(); + manager.sync_persisted_state(); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace, + CloudStorageError::NotFound("wallet files missing".into()), + ); + + let finalization = manager.prepare_passkey_repair_finalization().await.unwrap(); + manager.apply_passkey_repair_finalization(finalization).unwrap(); + + let state = Database::global().cloud_backup_state.get().unwrap(); + assert_eq!(state.status(), PersistedCloudBackupStatus::Enabled); + assert_eq!(state.wallet_count(), Some(7)); + assert_eq!(manager.model_snapshot().status, CloudBackupStatus::Enabled); +} + +#[tokio::test(flavor = "current_thread")] +async fn wrapper_repair_refreshes_missing_master_key_sync_health_to_uploading() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + manager.observe_sync_health(CloudSyncHealth::Failed( + SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into(), + )); + + run_repair_passkey_operation(&manager, true).await; + + for _ in 0..20 { + if manager.model_snapshot().sync_health == CloudSyncHealth::Uploading { + break; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert_eq!(manager.model_snapshot().sync_health, CloudSyncHealth::Uploading); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn wrapper_repair_fails_closed_when_wallet_listing_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace, + CloudStorageError::NotFound("wallet files missing".into()), + ); + + let error = manager.prepare_passkey_wrapper_repair_no_discovery().await.unwrap_err(); + + assert!(error.to_string().contains("wallet files missing"), "{error}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn wrapper_repair_reports_failure_after_upload_when_passkey_persistence_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + globals.keychain.fail_save_at(1); + + run_repair_passkey_operation(&manager, true).await; + + assert!(globals.cloud.has_master_key_backup(&namespace)); + assert_eq!(CloudBackupKeychain::global().load_credential_id(), None); + assert_eq!(CloudBackupKeychain::global().load_prf_salt(), None); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/pending_upload.rs b/rust/src/manager/cloud_backup_manager/ops/tests/pending_upload.rs new file mode 100644 index 000000000..27f1395c3 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/pending_upload.rs @@ -0,0 +1,729 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_state_changes_do_not_dismiss_verification_prompt() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + manager.backup_new_wallet(xpub_only_wallet_metadata()); + assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); + + manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Confirming); + assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); + + manager.reconcile_pending_upload_verification( + PendingUploadVerificationState::BlockedOnAuthorization, + ); + assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); + + manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); + let state = manager.model_snapshot(); + assert_eq!(state.root_prompt, CloudBackupRootPrompt::Verification); + assert!(matches!( + state.verification_presentation, + CloudBackupVerificationPresentation::NeedsDecision { + reason: CloudBackupVerificationReason::BackupChanged, + .. + } + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn verification_prompt_restored_when_pending_upload_idle_hides_persisted_decision() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + manager.backup_new_wallet(xpub_only_wallet_metadata()); + manager.reconcile_verification_presentation(CloudBackupVerificationPresentation::Hidden { + source: Some(CloudBackupVerificationSource::Settings), + }); + assert_eq!(manager.state.read().snapshot().root_prompt, CloudBackupRootPrompt::None); + + manager.reconcile_pending_upload_verification(PendingUploadVerificationState::Idle); + + let state = manager.model_snapshot(); + assert_eq!(state.root_prompt, CloudBackupRootPrompt::Verification); + assert!(matches!( + state.verification_presentation, + CloudBackupVerificationPresentation::NeedsDecision { + reason: CloudBackupVerificationReason::BackupChanged, + .. + } + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dismiss_verification_prompt_hides_pending_decision() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + manager.backup_new_wallet(xpub_only_wallet_metadata()); + assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); + + manager.dispatch(CloudBackupManagerAction::DismissVerificationPrompt); + + let state = manager.model_snapshot(); + assert_eq!(state.root_prompt, CloudBackupRootPrompt::None); + assert!(matches!( + state.verification_presentation, + CloudBackupVerificationPresentation::Hidden { .. } + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn start_verification_with_pending_upload_consumes_prompt_decision() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + let metadata = xpub_only_wallet_metadata(); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = wallet_record_id(metadata.id.as_ref()); + manager.backup_new_wallet(metadata.clone()); + manager + .mark_blob_uploaded_pending_confirmation( + &namespace_id, + CloudBackupRecordKey::Wallet(metadata.id, record_id), + "pending-revision".into(), + crate::manager::cloud_backup_manager::current_timestamp(), + ) + .unwrap(); + assert_eq!(manager.model_snapshot().root_prompt, CloudBackupRootPrompt::Verification); + + manager.dispatch(CloudBackupManagerAction::StartVerification( + CloudBackupVerificationSource::RootPrompt, + )); + + let state = manager.model_snapshot(); + assert_eq!(state.root_prompt, CloudBackupRootPrompt::None); + assert!(matches!( + state.verification_presentation, + CloudBackupVerificationPresentation::BackgroundConfirming( + CloudBackupVerificationSource::RootPrompt + ) + )); + assert_eq!(state.pending_upload_verification, PendingUploadVerificationState::Confirming); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_blocks_on_cloud_authorization() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + persist_pending_master_key_confirmation(namespace_id.clone(), "pending"); + globals + .cloud + .fail_master_key_download_authorization_required(namespace_id, "authorization required"); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(has_more_pending); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::BlockedOnAuthorization + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_preserves_newer_dirty_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = WalletMetadata::preview_new(); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace_id, + metadata.id.clone(), + record_id.clone(), + PersistedCloudBlobState::UploadedPendingConfirmation( + CloudBlobUploadedPendingConfirmationState { + revision_hash: "rev-1".into(), + uploaded_at: 10, + attempt_count: 0, + last_checked_at: None, + }, + ), + )) + .unwrap(); + globals.cloud.dirty_wallet_on_next_backup_check(metadata.id.clone()); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn start_verification_dispatch_resumes_pending_upload_verification() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + persist_pending_master_key_confirmation(namespace_id.clone(), "pending"); + manager.replace_pending_verification_completion(PendingVerificationCompletion::new( + DeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 0, + wallets_failed: 0, + wallets_unsupported: 0, + detail: None, + }, + namespace_id.clone(), + vec![PendingVerificationUpload::master_key_wrapper()], + )); + globals + .cloud + .fail_master_key_download_authorization_required(namespace_id, "authorization required"); + + manager.dispatch(CloudBackupManagerAction::StartVerification( + CloudBackupVerificationSource::Settings, + )); + + wait_for_test_condition( + Duration::from_secs(1), + "expected pending upload verification to pause on authorization", + || { + matches!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::BlockedOnAuthorization + ) + }, + ) + .await; + + assert!(manager.pending_verification_completion().is_some()); + assert!(!matches!(manager.model_snapshot().verification, VerificationState::Verifying)); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_finalizes_awaiting_deep_verify() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(manager.pending_verification_completion().is_none()); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Idle + ); + + match manager.model_snapshot().verification { + VerificationState::Verified(report) => { + assert_eq!(report.wallets_verified, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 0); + + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + } + other => { + panic!("expected verified result after pending upload verification, got {other:?}") + } + } + + assert!(!manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_keeps_master_key_wrapper_hash_mismatch_pending() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let expected_master_json = vec![1, 2, 3]; + let expected_revision = master_key_wrapper_revision_hash(&expected_master_json); + persist_pending_master_key_confirmation(namespace_id.clone(), expected_revision); + globals.cloud.set_master_key_backup(namespace_id.clone(), vec![4, 5, 6]); + manager.replace_pending_verification_completion(PendingVerificationCompletion::new( + DeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 0, + wallets_failed: 0, + wallets_unsupported: 0, + detail: None, + }, + namespace_id, + vec![PendingVerificationUpload::master_key_wrapper()], + )); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(has_more_pending); + assert!(manager.pending_verification_completion().is_some()); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Confirming + ); + assert!(matches!(manager.model_snapshot().verification, VerificationState::Idle)); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_expires_stale_completion() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + manager.replace_pending_verification_completion(PendingVerificationCompletion::new( + DeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 0, + wallets_failed: 0, + wallets_unsupported: 0, + detail: None, + }, + namespace_id, + vec![PendingVerificationUpload::master_key_wrapper()], + )); + + let mut state = RustCloudBackupManager::load_persisted_state(); + match &mut state { + PersistedCloudBackupState::Configured(configured) => { + configured + .pending_verification_completion + .as_mut() + .expect("pending verification completion") + .created_at = Some(0); + } + other => panic!("expected configured cloud backup state, got {other:?}"), + } + Database::global().cloud_backup_state.set(&state).unwrap(); + + manager.finalize_pending_verification_if_ready().await; + + assert!(manager.pending_verification_completion().is_none()); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Idle + ); + match manager.model_snapshot().verification { + VerificationState::Failed(DeepVerificationFailure::Retry { message, .. }) => { + assert_eq!( + message, + "cloud backup upload confirmation expired; start verification again" + ); + } + other => panic!("expected retry failure for stale completion, got {other:?}"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_refreshes_sync_health_to_all_uploaded() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + let master_revision = seed_verifiable_cloud_master_key(globals); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let metadata = xpub_only_wallet_metadata(); + let record_id = wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) + .load_master_key_from_store() + .unwrap() + .unwrap(); + let prepared = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &metadata, + metadata.wallet_mode, + ) + .await + .unwrap(); + + globals.cloud.set_wallet_backup( + namespace_id.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, &prepared.revision_hash, 1).await, + ); + globals + .cloud + .set_wallet_files(namespace_id.clone(), vec![wallet_filename_from_record_id(&record_id)]); + persist_pending_master_key_confirmation(namespace_id.clone(), master_revision); + manager + .mark_blob_uploaded_pending_confirmation( + &namespace_id, + CloudBackupRecordKey::Wallet(metadata.id, record_id.clone()), + prepared.revision_hash.clone(), + crate::manager::cloud_backup_manager::current_timestamp(), + ) + .unwrap(); + manager.replace_pending_verification_completion(PendingVerificationCompletion::new( + DeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 0, + wallets_failed: 0, + wallets_unsupported: 0, + detail: None, + }, + namespace_id, + vec![ + PendingVerificationUpload::master_key_wrapper(), + PendingVerificationUpload::new(record_id, prepared.revision_hash), + ], + )); + + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(manager.pending_verification_completion().is_none()); + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::AllUploaded,); + + for _ in 0..20 { + if manager.model_snapshot().sync_health == CloudSyncHealth::AllUploaded { + break; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert_eq!(manager.model_snapshot().sync_health, CloudSyncHealth::AllUploaded); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_survives_restart() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + assert!(manager.pending_verification_completion().is_some()); + + let restarted_manager = init_manager(); + + assert!(restarted_manager.pending_verification_completion().is_some()); + restarted_manager.sync_persisted_state(); + let has_more_pending = verify_pending_uploads_once_for_test_async(&restarted_manager).await; + + assert!(!has_more_pending); + assert!(restarted_manager.pending_verification_completion().is_none()); + match restarted_manager.model_snapshot().verification { + VerificationState::Verified(report) => { + assert_eq!(report.wallets_verified, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 0); + + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + } + other => { + panic!("expected verified result after restart, got {other:?}") + } + } + assert!(!restarted_manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_retries_until_expected_revision_is_readable() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) + .load_master_key_from_store() + .unwrap() + .unwrap(); + let current_revision = crate::manager::cloud_backup_manager::wallets::prepare_wallet_backup( + &metadata, + metadata.wallet_mode, + ) + .await + .unwrap() + .revision_hash; + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + globals.cloud.set_wallet_backup_download_override( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "stale-revision", 1).await, + ); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(has_more_pending); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); + assert!(!matches!( + manager.model_snapshot().verification, + VerificationState::Verified(_) | VerificationState::Failed(_) + )); + + globals.cloud.set_wallet_backup_download_override( + namespace, + record_id, + encrypted_wallet_backup_bytes(&metadata, &master_key, ¤t_revision, 1).await, + ); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(manager.pending_verification_completion().is_none()); + match manager.model_snapshot().verification { + VerificationState::Verified(report) => { + assert_eq!(report.wallets_verified, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 0); + } + other => panic!("expected verified result after retry, got {other:?}"), + } + assert!(!manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_accepts_newer_revision_after_wallet_changes() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.change_wallet_on_next_upload(metadata.id.clone()); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); + + manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); + + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(manager.pending_verification_completion().is_none()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Confirmed(_), .. }) + )); + + match manager.model_snapshot().verification { + VerificationState::Verified(report) => { + assert_eq!(report.wallets_verified, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.wallets_unsupported, 0); + + let detail = report.detail.expect("expected verification detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + } + other => panic!("expected verified result after newer upload, got {other:?}"), + } + + assert!(!manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_marks_invalid_wallet_json_failed() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + globals.cloud.set_wallet_backup(namespace, record_id.clone(), b"{".to_vec()); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(manager.pending_verification_completion().is_none()); + + match manager.model_snapshot().verification { + VerificationState::Verified(report) => { + assert_eq!(report.wallets_verified, 0); + assert_eq!(report.wallets_failed, 1); + assert_eq!(report.wallets_unsupported, 0); + } + other => { + panic!("expected verified result after pending upload verification, got {other:?}") + } + } + + assert!(!manager.has_pending_cloud_upload_verification()); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_upload_verification_marks_terminal_live_upload_failures_failed() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace_id.clone(), + metadata.id, + record_id.clone(), + PersistedCloudBlobState::UploadedPendingConfirmation( + CloudBlobUploadedPendingConfirmationState { + revision_hash: "rev-1".into(), + uploaded_at: 10, + attempt_count: 0, + last_checked_at: None, + }, + ), + )) + .unwrap(); + globals.cloud.set_wallet_backup(namespace_id, record_id.clone(), b"{".to_vec()); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert!(!manager.has_pending_cloud_upload_verification()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn failed_pending_upload_without_remote_backup_remains_pending() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = deep_verify_for_test(&manager, true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + assert!(manager.pending_verification_completion().is_some()); + + CloudStorage::global_silent_client() + .delete_wallet_backup(namespace_id.clone(), record_id.clone()) + .await + .unwrap(); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace_id, + metadata.id, + record_id.clone(), + PersistedCloudBlobState::Failed(CloudBlobFailedState { + revision_hash: Some("rev-1".into()), + retryable: false, + error: "terminal upload failure".into(), + issue: None, + failed_at: 10, + }), + )) + .unwrap(); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(has_more_pending); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs b/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs new file mode 100644 index 000000000..8435efcb7 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs @@ -0,0 +1,993 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn restore_downloaded_wallet_does_not_reupload_wallet_or_mutate_backup_counts() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 5); + + let metadata = xpub_only_wallet_metadata(); + let wallet = DownloadedWalletBackup { + metadata: metadata.clone(), + entry: WalletEntry { + wallet_id: metadata.id.to_string(), + secret: WalletSecret::WatchOnly, + metadata: serde_json::to_value(&metadata).unwrap(), + descriptors: None, + xpub: Some(sample_xpub(&metadata)), + wallet_mode: CloudWalletMode::Main, + labels_zstd_jsonl: None, + labels_count: 0, + labels_hash: None, + labels_uncompressed_size: None, + content_revision_hash: "test-content-hash".to_string(), + updated_at: 42, + }, + }; + + WalletRestoreSession::new(crate::wallet_identity::ExistingWalletIdentitySet::default()) + .restore_downloaded(&wallet) + .unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(5)); + assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); + assert!( + Database::global() + .wallets() + .get(&metadata.id, metadata.network, WalletMode::Main) + .unwrap() + .is_some() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_downloaded_wallet_restores_labels_without_marking_cloud_backup_dirty() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 5); + + let locked_output_ref = "d97bf8892657980426c879e4ab2001f09342f1ab61cfa602741a7715a3d60290:0"; + let labels_jsonl = format!( + "{}\n{{\"type\":\"output\",\"ref\":\"{locked_output_ref}\",\"spendable\":false}}", + sample_labels_jsonl() + ); + let metadata = xpub_only_wallet_metadata(); + let wallet = DownloadedWalletBackup { + metadata: metadata.clone(), + entry: wallet_entry_with_labels(&metadata, Some(&labels_jsonl)), + }; + + let outcome = + WalletRestoreSession::new(crate::wallet_identity::ExistingWalletIdentitySet::default()) + .restore_downloaded(&wallet) + .unwrap(); + + let WalletRestoreOutcome::Restored { labels_warning } = outcome else { + panic!("expected restored wallet"); + }; + assert!(labels_warning.is_none()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(5)); + assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); + + let exported = LabelManager::new(metadata.id.clone()).export().await.unwrap(); + assert!(exported.contains("\"label\":\"last txn received\"")); + assert!(exported.contains(&format!("\"ref\":\"{locked_output_ref}\""))); + assert!(exported.contains("\"spendable\":false")); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_from_local_master_key_propagates_store_read_errors() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let store = Arc::new(MockStore::default()); + let store_handle = MockStoreHandle(store.clone()); + let cspp = cove_cspp::Cspp::new(store_handle); + let expected = cove_cspp::master_key::MasterKey::generate(); + cspp.save_master_key(&expected).unwrap(); + let key_to_corrupt = + store.entries.lock().keys().next().cloned().expect("saved master key entry"); + store.entries.lock().insert(key_to_corrupt, "not-a-valid-master-key".into()); + + let error = + match try_restore_from_local_master_key(&CloudStorage::global_explicit_client(), &cspp) + .await + { + Ok(_) => panic!("expected local master key read failure"), + Err(error) => error, + }; + + assert!(matches!( + error, + CloudBackupError::Internal(message) + if message.starts_with("loading master key from store:") + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_counts_unsupported_wallet_versions_as_failures() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let supported_wallet = xpub_only_wallet_metadata(); + let unsupported_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) + .unwrap(); + Keychain::global() + .save_wallet_xpub(&unsupported_wallet.id, sample_xpub(&unsupported_wallet).parse().unwrap()) + .unwrap(); + + let supported_record_id = + cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); + let unsupported_record_id = + cove_cspp::backup_data::wallet_record_id(unsupported_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + supported_record_id.clone(), + encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) + .await, + ); + globals.cloud.set_wallet_backup( + namespace.clone(), + unsupported_record_id.clone(), + encrypted_wallet_backup_bytes(&unsupported_wallet, &master_key, "unsupported-revision", 2) + .await, + ); + globals.cloud.set_wallet_files( + namespace, + vec![ + wallet_filename_from_record_id(&supported_record_id), + wallet_filename_from_record_id(&unsupported_record_id), + ], + ); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 1); + assert_eq!(report.failed_wallet_errors.len(), 1); + assert!(report.failed_wallet_errors[0].contains("unsupported wallet backup version 2")); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); + assert!( + Database::global() + .wallets() + .get(&supported_wallet.id, supported_wallet.network, supported_wallet.wallet_mode,) + .unwrap() + .is_some() + ); + assert!( + Database::global() + .wallets() + .get( + &unsupported_wallet.id, + unsupported_wallet.network, + unsupported_wallet.wallet_mode, + ) + .unwrap() + .is_none() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_queues_reupload_when_cloud_upload_confirmation_lags() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + + let record_id = wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &master_key, "restored-revision", 1).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + globals.cloud.set_uploaded_wallets_pending_confirmation(true); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 1); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap(); + assert!( + matches!( + sync_state, + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + ), + "unexpected sync state: {sync_state:?}" + ); + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_with_one_passkey_restores_wallets_from_all_matching_namespaces() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + let sample_xpub_from_entropy = |metadata: &WalletMetadata, byte| { + let entropy = [byte; 16]; + let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); + + crate::mnemonic::MnemonicExt::xpub(&mnemonic, metadata.network.into()).to_string() + }; + Keychain::global() + .save_wallet_xpub( + &first_wallet.id, + sample_xpub_from_entropy(&first_wallet, 1).parse().unwrap(), + ) + .unwrap(); + Keychain::global() + .save_wallet_xpub( + &second_wallet.id, + sample_xpub_from_entropy(&second_wallet, 2).parse().unwrap(), + ) + .unwrap(); + + let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + first_namespace.clone(), + first_record_id.clone(), + encrypted_wallet_backup_bytes(&first_wallet, &first_master_key, "first-revision", 1).await, + ); + globals.cloud.set_wallet_backup( + second_namespace.clone(), + second_record_id.clone(), + encrypted_wallet_backup_bytes(&second_wallet, &second_master_key, "second-revision", 1) + .await, + ); + globals + .cloud + .set_wallet_files(first_namespace, vec![wallet_filename_from_record_id(&first_record_id)]); + globals.cloud.set_wallet_files( + second_namespace, + vec![wallet_filename_from_record_id(&second_record_id)], + ); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + assert_eq!(report.wallets_restored, 2); + assert_eq!(report.wallets_failed, 0); + assert!(report.failed_wallet_errors.is_empty(), "{:?}", report.failed_wallet_errors); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(2)); + let active_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + for record_id in [&first_record_id, &second_record_id] { + let sync_state = Database::global().cloud_blob_sync_states.get(record_id).unwrap().unwrap(); + assert_eq!(sync_state.namespace_id, active_namespace); + assert!( + matches!(sync_state.state, PersistedCloudBlobState::Dirty(_)), + "unexpected sync state: {sync_state:?}" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_missing_wallet_listing_fails_closed_without_finalizing_empty_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + configure_enabled_cloud_backup(&manager, globals, 7); + let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace, + CloudStorageError::NotFound("wallet files missing".into()), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(error.to_string().contains("wallet files missing"), "{error}"); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_activation_upload_failure_keeps_restore_successful_and_queues_upload() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let wallet = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &master_key, "restore-revision", 1).await, + ); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); + globals.cloud.fail_wallet_backup_upload("activation upload failed"); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let (sender, receiver) = flume::bounded(1); + call!(manager.supervisor.start_restore_from_cloud_backup_with_events(sender)).await.unwrap(); + + let report = tokio::time::timeout(Duration::from_secs(1), async { + loop { + match receiver.recv_async().await.expect("receive restore event") { + CloudBackupRestoreEvent::Progress(_) => {} + CloudBackupRestoreEvent::Complete(report) => break report, + CloudBackupRestoreEvent::Failed(message) => { + panic!("restore should complete before background upload failure: {message}"); + } + other => panic!("expected restore completion event, got {other:?}"), + } + } + }) + .await + .expect("restore completion event"); + + assert_eq!(report.wallets_restored, 1); + wait_for_test_condition( + Duration::from_secs(5), + "background reupload should fail after restore and remain queued for retry", + || { + let upload_attempted = globals.cloud.wallet_backup_upload_attempt_count() > 0; + let retryable_failure = matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::Failed(CloudBlobFailedState { + retryable: true, + .. + }), + .. + }) + ); + + upload_attempted && retryable_failure + }, + ) + .await; + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace.clone())); + let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap().unwrap(); + assert_eq!(sync_state.namespace_id, namespace); + assert!( + matches!( + sync_state.state, + PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: true, .. }) + ), + "unexpected sync state: {sync_state:?}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_all_duplicate_wallets_preserves_existing_configured_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + configure_enabled_cloud_backup(&manager, globals, 7); + let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let duplicate_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![duplicate_wallet.clone()]); + + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) + .await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 0); + assert_eq!(report.wallets_failed, 0); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_all_duplicate_wallets_activates_namespace_when_persisted_state_is_empty() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let duplicate_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![duplicate_wallet.clone()]); + + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) + .await, + ); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 0); + assert_eq!(report.wallets_failed, 0); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace.clone())); + let sync_state = Database::global().cloud_blob_sync_states.get(&record_id).unwrap().unwrap(); + assert_eq!(sync_state.namespace_id, namespace); + assert!( + matches!(sync_state.state, PersistedCloudBlobState::Dirty(_)), + "unexpected sync state: {sync_state:?}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_duplicate_wallets_with_failures_preserves_existing_configured_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + configure_enabled_cloud_backup(&manager, globals, 7); + let existing_namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let duplicate_wallet = xpub_only_wallet_metadata(); + let missing_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![duplicate_wallet.clone()]); + + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + let duplicate_record_id = + cove_cspp::backup_data::wallet_record_id(duplicate_wallet.id.as_ref()); + let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.cloud.set_wallet_backup( + namespace.clone(), + duplicate_record_id.clone(), + encrypted_wallet_backup_bytes(&duplicate_wallet, &master_key, "duplicate-revision", 1) + .await, + ); + globals.cloud.set_wallet_files( + namespace, + vec![ + wallet_filename_from_record_id(&duplicate_record_id), + wallet_filename_from_record_id(&missing_record_id), + ], + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 0); + assert_eq!(report.wallets_failed, 1); + assert!(report.failed_wallet_errors[0].contains("was listed but missing from cloud backup")); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(7)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(existing_namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_empty_namespace_list_returns_no_backup_found() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(matches!(error, CloudBackupError::NoBackupFound)); + assert_eq!(CloudBackupKeychain::global().namespace_id(), None); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_retries_platform_authorization_discover_failures() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &prf_key, &[9; 32]).unwrap(); + + globals.cloud.set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted).unwrap()); + globals.passkey.push_discover_result(Err(platform_authorization_failed())); + globals.passkey.push_discover_result(Err(platform_authorization_failed())); + globals.passkey.push_discover_result(Err(platform_authorization_failed())); + globals.passkey.push_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &master_key, "revision", 1).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_does_not_persist_first_passkey_match_before_restore_work_succeeds() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&second_master_key, &prf_key, &[8; 32]) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.cloud.set_wallet_files(first_namespace, vec!["wallet-1.json".into()]); + globals.cloud.set_wallet_files(second_namespace, vec!["wallet-2.json".into()]); + globals.cloud.fail_list_wallet_files("list failed"); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(error.to_string().contains("list failed"), "{error}"); + assert_eq!(CloudBackupKeychain::global().namespace_id(), None); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_counts_listed_missing_wallet_backups_as_failures() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let supported_wallet = xpub_only_wallet_metadata(); + let missing_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) + .unwrap(); + let supported_record_id = + cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); + let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + supported_record_id.clone(), + encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) + .await, + ); + globals.cloud.set_wallet_files( + namespace, + vec![ + wallet_filename_from_record_id(&supported_record_id), + wallet_filename_from_record_id(&missing_record_id), + ], + ); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 1); + assert!(report.failed_wallet_errors[0].contains("was listed but missing from cloud backup")); + assert!(report.labels_failed_wallet_names.is_empty()); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_sanitizes_non_connectivity_wallet_download_errors() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let supported_wallet = xpub_only_wallet_metadata(); + let failed_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&supported_wallet.id, sample_xpub(&supported_wallet).parse().unwrap()) + .unwrap(); + + let supported_record_id = + cove_cspp::backup_data::wallet_record_id(supported_wallet.id.as_ref()); + let failed_record_id = cove_cspp::backup_data::wallet_record_id(failed_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + supported_record_id.clone(), + encrypted_wallet_backup_bytes(&supported_wallet, &master_key, "supported-revision", 1) + .await, + ); + globals.cloud.fail_wallet_backup_download( + namespace.clone(), + failed_record_id.clone(), + CloudStorageError::DownloadFailed(format!("raw record id {failed_record_id}")), + ); + globals.cloud.set_wallet_files( + namespace, + vec![ + wallet_filename_from_record_id(&supported_record_id), + wallet_filename_from_record_id(&failed_record_id), + ], + ); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 1); + assert_eq!(report.failed_wallet_errors, vec!["wallet backup could not be read"]); + assert!(!report.failed_wallet_errors[0].contains(&failed_record_id)); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_reports_label_warning_without_failing_wallet_restore() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let wallet = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + let entry = wallet_entry_with_labels(&wallet, Some("{")); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes_for_entry(&entry, &master_key, 1), + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(report.labels_failed_wallet_names, vec![wallet.name.clone()]); + assert_eq!(report.labels_failed_errors.len(), 1); + assert!( + report.labels_failed_errors[0].contains("Failed to parse labels") + || report.labels_failed_errors[0].contains("failed to parse") + ); + assert!( + Database::global() + .wallets() + .get(&wallet.id, wallet.network, wallet.wallet_mode) + .unwrap() + .is_some() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_cloud_wallet_returns_label_warning_without_failing_restore() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + CloudBackupKeychain::global().save_namespace_id(&namespace).unwrap(); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + manager + .persist_cloud_backup_state( + &persisted_enabled_cloud_backup_state(None), + "enable cloud backup for restore cloud wallet test", + ) + .unwrap(); + + let wallet = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + let entry = wallet_entry_with_labels(&wallet, Some("{")); + globals.cloud.set_wallet_backup( + namespace, + record_id.clone(), + encrypted_wallet_backup_bytes_for_entry(&entry, &master_key, 1), + ); + + let outcome = manager.do_restore_cloud_wallet(&record_id).await.unwrap(); + + let WalletRestoreOutcome::Restored { labels_warning } = outcome else { + panic!("expected restored wallet"); + }; + let warning = labels_warning.expect("expected label warning"); + assert_eq!(warning.wallet_name, wallet.name); + assert!( + warning.error.contains("Failed to parse labels") + || warning.error.contains("failed to parse") + ); + assert!( + Database::global() + .wallets() + .get(&wallet.id, wallet.network, wallet.wallet_mode) + .unwrap() + .is_some() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_fails_when_all_wallet_backups_are_unsupported() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&wallet, &master_key, "unsupported-revision", 2).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::Internal(message) if message == "all wallets failed to restore" + )); + + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_fails_when_all_listed_wallet_backups_are_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let missing_wallet = xpub_only_wallet_metadata(); + let missing_record_id = cove_cspp::backup_data::wallet_record_id(missing_wallet.id.as_ref()); + globals + .cloud + .set_wallet_files(namespace, vec![wallet_filename_from_record_id(&missing_record_id)]); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::Internal(message) if message == "all wallets failed to restore" + )); + + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs b/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs new file mode 100644 index 000000000..b6d3d59f8 --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs @@ -0,0 +1,696 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn failed_blob_states_recover_only_after_last_failed_wallet_upload_recovers() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); + + persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); + persist_dirty_blob_state(first_wallet.id.clone()); + persist_dirty_blob_state(second_wallet.id.clone()); + globals.cloud.fail_wallet_backup_upload("upload failed"); + + run_wallet_upload_for_test_async(&manager, first_wallet.id.clone()).await; + run_wallet_upload_for_test_async(&manager, second_wallet.id.clone()).await; + + assert!(matches!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(message) if message.contains("upload failed") + )); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&first_record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) + )); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) + )); + + globals.cloud.clear_wallet_backup_upload_failure(); + + run_wallet_upload_for_test_async(&manager, first_wallet.id.clone()).await; + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(message) if message.contains("upload failed") + )); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&first_record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Failed(_), .. }) + )); + + run_wallet_upload_for_test_async(&manager, second_wallet.id.clone()).await; + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 2); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&second_record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); + assert!( + Database::global() + .cloud_blob_sync_states + .list() + .unwrap() + .into_iter() + .all(|state| !matches!(state.state, PersistedCloudBlobState::Failed(_))) + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn corrupt_blob_sync_state_projects_failed_sync_health() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::corrupted( + "failed to decode persisted cloud backup blob sync state".into(), + )) + .unwrap(); + + assert!(matches!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(message) + if message.contains("failed to decode persisted cloud backup blob sync state") + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_reports_authorization_required_for_persisted_auth_failures() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + persist_failed_blob_state_with_issue( + metadata.id, + true, + Some(CloudStorageIssue::AuthorizationRequired), + ); + + assert_eq!( + manager.compute_sync_health().await, + CloudSyncHealth::AuthorizationRequired("failed".into()), + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_reports_uploading_for_fresh_pending_master_key_confirmation() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let master_json = vec![1, 2, 3]; + let master_revision = master_key_wrapper_revision_hash(&master_json); + persist_pending_master_key_confirmation(namespace_id.clone(), master_revision); + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading); + + globals.cloud.set_master_key_backup(namespace_id, master_json); + + let has_more_pending = verify_pending_uploads_once_for_test_async(&manager).await; + + assert!(!has_more_pending); + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::AllUploaded); + assert_eq!( + manager.model_snapshot().pending_upload_verification, + PendingUploadVerificationState::Idle + ); + assert!(matches!(manager.model_snapshot().verification, VerificationState::Idle)); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_reports_uploading_for_fresh_pending_master_key_with_local_wallets() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + persist_pending_master_key_confirmation(namespace_id, "pending"); + + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn force_new_reports_uploading_while_master_key_confirmation_is_pending() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + globals.passkey.set_create_result(Ok(vec![1, 2, 3])); + globals.passkey.set_authenticate_result(Ok(vec![7; 32])); + let existing_namespace = "fedcba9876543210fedcba9876543210"; + globals.cloud.set_master_key_backup(existing_namespace.into(), vec![1, 2, 3]); + globals.cloud.set_wallet_files(existing_namespace.into(), vec!["wallet-1.json".into()]); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + + enable_cloud_backup_no_discovery(&manager).await.unwrap(); + enable_cloud_backup_force_new(&manager).await.unwrap(); + confirm_saved_passkey_session(&manager).await; + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudStorage::global_silent_client() + .delete_wallet_backup(namespace_id.clone(), cspp_master_key_record_id()) + .await + .unwrap(); + persist_pending_master_key_confirmation(namespace_id, "pending"); + + assert_eq!(manager.compute_sync_health().await, CloudSyncHealth::Uploading,); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_reports_missing_master_key_without_pending_confirmation() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + + assert_eq!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into()), + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_reports_missing_master_key_before_pending_wallet_uploads() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + persist_dirty_blob_state(metadata.id); + + assert_eq!( + manager.compute_sync_health().await, + CloudSyncHealth::Failed(SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE.into()), + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_health_respects_master_key_upload_confirmation_grace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + + assert_eq!( + manager.compute_sync_health_with_master_key_grace(Some(&namespace_id)).await, + CloudSyncHealth::Uploading, + ); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn validate_metadata_marks_generated_wallet_names_dirty() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + reset_cloud_backup_test_state(&manager, globals); + + let mut metadata = WalletMetadata::preview_new(); + metadata.name.clear(); + + let wallet = Wallet::try_new_persisted_and_selected( + metadata, + Mnemonic::parse( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap(), + None, + ) + .unwrap(); + let wallet_id = wallet.id(); + let stored_metadata = Database::global() + .wallets() + .get(&wallet_id, wallet.network, wallet.metadata.wallet_mode) + .unwrap() + .unwrap(); + let expected_name = stored_metadata + .master_fingerprint + .as_deref() + .map_or_else(|| "Unnamed Wallet".to_string(), |fingerprint| fingerprint.as_uppercase()); + + enable_cloud_backup_without_reset(&manager, 1); + + let wallet_manager = RustWalletManager::try_new(wallet_id.clone()).unwrap(); + wallet_manager.validate_metadata(); + + let updated_metadata = Database::global() + .wallets() + .get(&wallet_id, wallet.network, wallet.metadata.wallet_mode) + .unwrap() + .unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet_id.as_ref()); + + assert_eq!(updated_metadata.name, expected_name); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_and_integrity_skip_pending_upload_candidates() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = WalletType::WatchOnly; + Database::global() + .wallets() + .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) + .unwrap(); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace_id, + metadata.id.clone(), + record_id, + PersistedCloudBlobState::UploadedPendingConfirmation( + CloudBlobUploadedPendingConfirmationState { + revision_hash: "rev-1".into(), + uploaded_at: 10, + attempt_count: 0, + last_checked_at: None, + }, + ), + )) + .unwrap(); + + manager.do_sync_unsynced_wallets().await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); + + let warning = manager.verify_backup_integrity_impl().await.expect("expected passkey warning"); + + assert!(!warning.contains("some wallets are not backed up")); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(1)); +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_does_not_retry_sync_after_auto_backup_failure() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = WalletType::WatchOnly; + Database::global() + .wallets() + .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata]) + .unwrap(); + globals.cloud.fail_wallet_backup_upload("offline"); + + let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); + + assert!(warning.contains("some wallets are not backed up")); + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_warns_when_background_wallet_list_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + globals.cloud.fail_list_wallet_files_non_interactive("offline"); + + let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); + + assert!(warning.contains("wallet backups could not be listed")); + globals.cloud.clear_list_wallet_files_non_interactive_failure(); +} + +#[tokio::test(flavor = "current_thread")] +async fn refresh_cloud_backup_detail_uses_interactive_wallet_listing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + + let keychain = Keychain::global(); + let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); + CloudBackupKeychain::new(keychain.clone()) + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + let master_key = + cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "interactive-revision", 1).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + globals.cloud.fail_list_wallet_files_non_interactive("offline"); + + let Some(CloudBackupDetailResult::Success(detail)) = + manager.refresh_cloud_backup_detail().await + else { + panic!("expected cloud backup detail"); + }; + + assert_eq!(1, detail.up_to_date.len() + detail.needs_sync.len()); + let listed_record_id = detail + .up_to_date + .first() + .map(|wallet| wallet.record_id.clone()) + .or_else(|| detail.needs_sync.first().map(|wallet| wallet.record_id.clone())) + .expect("expected listed wallet"); + assert_eq!(listed_record_id, record_id); + globals.cloud.clear_list_wallet_files_non_interactive_failure(); +} + +#[tokio::test(flavor = "current_thread")] +async fn refresh_cloud_backup_detail_returns_access_error_when_wallet_listing_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.fail_list_wallet_files_for_namespace( + namespace, + CloudStorageError::NotFound("wallet files missing".into()), + ); + + let Some(CloudBackupDetailResult::AccessError(error)) = + manager.refresh_cloud_backup_detail().await + else { + panic!("expected cloud backup detail access error"); + }; + + assert!(error.to_string().contains("wallet files missing"), "{error}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_preserves_unsupported_remote_wallet_backups() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + + let keychain = Keychain::global(); + let namespace = CloudBackupKeychain::new(keychain.clone()).namespace_id().unwrap(); + CloudBackupKeychain::new(keychain.clone()) + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + let master_key = + cove_cspp::Cspp::new(keychain.clone()).load_master_key_from_store().unwrap().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "unsupported-revision", 2).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let warning = manager.verify_backup_integrity_impl().await; + + assert!(warning.is_none()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + + let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::UnsupportedVersion); +} + +#[tokio::test(flavor = "current_thread")] +async fn refresh_cloud_backup_detail_marks_listed_wallet_unknown_when_master_key_is_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + reset_cloud_backup_test_state(&manager, globals); + + let metadata = xpub_only_wallet_metadata(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let keychain = Keychain::global(); + CloudBackupKeychain::new(keychain.clone()).save_namespace_id(&namespace).unwrap(); + cove_cspp::Cspp::new(keychain.clone()).save_master_key(&master_key).unwrap(); + manager + .persist_cloud_backup_state( + &persisted_enabled_cloud_backup_state(Some(1)), + "set cloud backup enabled for test", + ) + .unwrap(); + manager.sync_persisted_state(); + + persist_xpub_wallets(vec![metadata.clone()]); + + let record_id = wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "rev-1", 1).await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + let cspp = cove_cspp::Cspp::new(keychain.clone()); + cspp.delete_master_key(); + cove_cspp::Cspp::::clear_cached_master_key(); + + let Some(CloudBackupDetailResult::Success(detail)) = + manager.refresh_cloud_backup_detail().await + else { + panic!("expected cloud backup detail"); + }; + + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::RemoteStateUnknown); +} + +#[tokio::test(flavor = "current_thread")] +async fn sync_skips_wallets_with_unknown_remote_truth() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals + .cloud + .set_wallet_files(namespace.clone(), vec![wallet_filename_from_record_id(&record_id)]); + globals.cloud.set_wallet_backup(namespace, record_id, b"{".to_vec()); + + manager.do_sync_unsynced_wallets().await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_refreshes_detail_after_auto_backup_success() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + + let warning = manager.verify_backup_integrity_impl().await; + + assert!(warning.is_none()); + let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); + assert_eq!(detail.up_to_date.len(), 1); + assert!(detail.needs_sync.is_empty()); + assert_eq!(detail.up_to_date[0].record_id, record_id); + assert_eq!(detail.up_to_date[0].sync_status, CloudBackupWalletStatus::Confirmed); + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_auto_backup_continues_when_other_backup_summary_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + globals.cloud.fail_list_namespaces("offline while listing namespaces"); + + let warning = manager.verify_backup_integrity_impl().await; + + assert!(warning.is_none()); + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); + let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); + assert!(matches!(detail.other_backups, CloudBackupOtherBackupsState::LoadFailed { .. })); + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_does_not_retry_sync_after_auto_backup_success_when_listing_stays_empty() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + + let warning = manager.verify_backup_integrity_impl().await; + + assert!(warning.is_none()); + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn integrity_refreshes_detail_after_auto_backup_failure() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[1, 2, 3, 4], [9; 32], &namespace) + .unwrap(); + globals.cloud.fail_wallet_backup_upload("offline"); + + let warning = manager.verify_backup_integrity_impl().await.expect("expected integrity warning"); + + assert!(warning.contains("some wallets are not backed up")); + let detail = manager.model_snapshot().detail.expect("expected cloud backup detail"); + assert_eq!(detail.needs_sync.len(), 1); + assert_eq!(detail.needs_sync[0].record_id, record_id); + assert_eq!(detail.needs_sync[0].sync_status, CloudBackupWalletStatus::Dirty); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/upload.rs b/rust/src/manager/cloud_backup_manager/ops/tests/upload.rs new file mode 100644 index 000000000..89dab712a --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/ops/tests/upload.rs @@ -0,0 +1,577 @@ +use super::*; + +#[test] +fn persist_xpub_wallets_saves_each_wallet_in_its_own_scope() { + let _guard = test_lock().lock(); + let globals = test_globals(); + globals.reset(); + + let first_wallet = xpub_only_wallet_metadata(); + let mut second_wallet = xpub_only_wallet_metadata(); + second_wallet.wallet_mode = WalletMode::Decoy; + + Database::global() + .wallets() + .save_all_wallets(first_wallet.network, first_wallet.wallet_mode, Vec::new()) + .unwrap(); + Database::global() + .wallets() + .save_all_wallets(second_wallet.network, second_wallet.wallet_mode, Vec::new()) + .unwrap(); + + persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); + + assert!( + Database::global() + .wallets() + .get(&first_wallet.id, first_wallet.network, first_wallet.wallet_mode) + .unwrap() + .is_some() + ); + assert!( + Database::global() + .wallets() + .get(&second_wallet.id, second_wallet.network, second_wallet.wallet_mode) + .unwrap() + .is_some() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_wallets_uploads_when_cloud_backup_is_enabled() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + let metadata = xpub_only_wallet_metadata(); + let xpub = sample_xpub(&metadata); + Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); + + manager.do_backup_wallets(&[metadata]).await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(4)); + assert!(Database::global().cloud_blob_sync_states.list().unwrap().into_iter().any( + |state| matches!(state.state, PersistedCloudBlobState::UploadedPendingConfirmation(_)) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_wallets_persists_partial_uploads_when_later_wallet_fails() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + let first_wallet = xpub_only_wallet_metadata(); + let mut second_wallet = WalletMetadata::preview_new(); + second_wallet.wallet_type = WalletType::Hot; + Keychain::global() + .save_wallet_xpub(&first_wallet.id, sample_xpub(&first_wallet).parse().unwrap()) + .unwrap(); + + let error = + manager.do_backup_wallets(&[first_wallet.clone(), second_wallet]).await.unwrap_err(); + + let record_id = wallet_record_id(first_wallet.id.as_ref()); + assert!(error.to_string().contains("has no mnemonic"), "{error}"); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(4)); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_wallets_defers_local_completion_when_disable_starts_after_upload() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + let metadata = xpub_only_wallet_metadata(); + let xpub = sample_xpub(&metadata); + Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let PersistedCloudBackupState::Configured(previous_configured) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected configured cloud backup state"); + }; + globals.cloud.persist_disabling_on_next_upload(PersistedDisablingCloudBackup { + previous_configured, + namespace_id, + disable_generation: 42, + started_at: 100, + delete_started_at: None, + last_error: None, + retry_after: None, + }); + + let error = manager.do_backup_wallets(&[metadata]).await.unwrap_err(); + + assert!(matches!(error, CloudBackupError::Deferred(_))); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + Database::global().cloud_backup_state.get().unwrap(), + PersistedCloudBackupState::Disabling(_) + )); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(3)); + assert!(Database::global().cloud_blob_sync_states.list().unwrap().is_empty()); + manager.debug_reset_cloud_backup_state(); +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_new_wallet_marks_verification_required() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 3); + + manager.backup_new_wallet(xpub_only_wallet_metadata()); + + let state = Database::global().cloud_backup_state.get().unwrap(); + assert_eq!(state.status(), PersistedCloudBackupStatus::Unverified); + assert!(state.last_verification_requested_at().is_some()); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_new_wallet_still_tracks_when_runtime_status_is_error() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + globals.reset(); + + let namespace = "test-namespace".to_string(); + CloudBackupKeychain::global().save_namespace_id(&namespace).unwrap(); + Database::global().cloud_blob_sync_states.delete_all().unwrap(); + manager + .persist_cloud_backup_state( + &persisted_enabled_cloud_backup_state(Some(3)), + "set cloud backup enabled for test", + ) + .unwrap(); + manager.sync_persisted_state(); + + let metadata = xpub_only_wallet_metadata(); + let record_id = wallet_record_id(metadata.id.as_ref()); + manager.reconcile_runtime_status(CloudBackupStatus::Error("offline".into())); + + manager.backup_new_wallet(metadata); + + let state = Database::global().cloud_backup_state.get().unwrap(); + assert_eq!(state.status(), PersistedCloudBackupStatus::Unverified); + assert!(state.last_verification_requested_at().is_some()); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn reupload_all_wallets_does_not_create_master_key_for_existing_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + CloudBackupKeychain::global().save_namespace_id("0123456789abcdef0123456789abcdef").unwrap(); + + let manager = init_manager(); + let claim = + CloudBackupExclusiveOperationClaim::new(CloudBackupExclusiveOperation::RecreateManifest, 1); + let writes = operation_write_client_for_test(&manager, claim); + let error = manager.prepare_reupload_all_wallets(writes).await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::RecoveryRequired(message) + if message == RECREATE_MANIFEST_RECOVERY_MESSAGE + )); + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn reupload_all_wallets_persists_full_cloud_wallet_count() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata]); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + globals.cloud.set_reflect_uploaded_wallets_in_listing(true); + globals + .cloud + .set_wallet_files(namespace, vec![wallet_filename_from_record_id("cloud-only-record")]); + + run_recreate_manifest(&manager).await; + + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count(), Some(2)); + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_wallets_does_not_create_master_key_or_upload_when_missing() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let namespace = "0123456789abcdef0123456789abcdef"; + CloudBackupKeychain::global().save_namespace_id(namespace).unwrap(); + + let manager = init_manager(); + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = crate::wallet::metadata::WalletType::WatchOnly; + + let error = manager.do_backup_wallets(&[metadata]).await.unwrap_err(); + + match error { + CloudBackupError::RecoveryRequired(message) => { + assert_eq!(message, "Cloud backup needs verification before wallets can be uploaded"); + } + error => panic!("expected recovery-required upload error, got {error:?}"), + } + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_does_not_create_master_key_for_existing_namespace() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let namespace = "0123456789abcdef0123456789abcdef"; + CloudBackupKeychain::global().save_namespace_id(namespace).unwrap(); + + let manager = init_manager(); + let metadata = xpub_only_wallet_metadata(); + let xpub = sample_xpub(&metadata); + Keychain::global().save_wallet_xpub(&metadata.id, xpub.parse().unwrap()).unwrap(); + Database::global() + .wallets() + .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) + .unwrap(); + + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace.into(), + metadata.id.clone(), + record_id.clone(), + PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), + )) + .unwrap(); + + let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); + + match error { + CloudBackupError::RecoveryRequired(message) => { + assert_eq!(message, "Cloud backup needs verification before wallets can be uploaded"); + } + error => panic!("expected recovery-required upload error, got {error:?}"), + } + + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert!(cspp.load_master_key_from_store().unwrap().is_none()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::Failed(CloudBlobFailedState { retryable: false, .. }), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn deferred_live_wallet_upload_retries_without_restart() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = global_manager(); + clear_wallet_upload_runtime_for_test_async(&manager).await; + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_xpub_wallets(vec![metadata.clone()]); + persist_dirty_blob_state(metadata.id.clone()); + globals.cloud.fail_next_wallet_backup_upload_offline("offline"); + + run_wallet_upload_for_test_async(&manager, metadata.id.clone()).await; + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(globals.cloud.wallet_backup_upload_attempt_count(), 1); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); + + wait_for_test_condition( + Duration::from_secs(7), + "deferred live upload should retry automatically after the backoff", + || globals.cloud.wallet_backup_upload_attempt_count() >= 2, + ) + .await; + + wait_for_test_condition( + Duration::from_secs(1), + "deferred live upload should eventually reach an uploaded state", + || { + matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + ) + }, + ) + .await; + assert!(globals.cloud.uploaded_wallet_backup_count() >= 1); + + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_removes_deleted_wallet_sync_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = WalletMetadata::preview_new(); + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace.clone(), + metadata.id.clone(), + record_id.clone(), + PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), + )) + .unwrap(); + + manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); + + assert!(Database::global().cloud_blob_sync_states.get(&record_id).unwrap().is_none()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert_eq!(CloudBackupKeychain::global().namespace_id(), Some(namespace)); +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_preserves_newer_dirty_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = WalletType::WatchOnly; + Database::global() + .wallets() + .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) + .unwrap(); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState::wallet( + namespace_id, + metadata.id.clone(), + record_id.clone(), + PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at: 1 }), + )) + .unwrap(); + globals.cloud.dirty_wallet_on_next_upload(metadata.id.clone()); + + manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_recovers_deferred_write_to_dirty() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + persist_dirty_blob_state(metadata.id.clone()); + + let namespace_id = CloudBackupKeychain::global().namespace_id().unwrap(); + let record_id = wallet_record_id(metadata.id.as_ref()); + let PersistedCloudBackupState::Configured(previous_configured) = + Database::global().cloud_backup_state.get().unwrap() + else { + panic!("expected configured cloud backup state"); + }; + globals.cloud.persist_disabling_on_next_upload(PersistedDisablingCloudBackup { + previous_configured, + namespace_id, + disable_generation: 42, + started_at: 100, + delete_started_at: None, + last_error: None, + retry_after: None, + }); + + let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); + + assert!(matches!(error, CloudBackupError::Deferred(_))); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + Database::global().cloud_backup_state.get().unwrap(), + PersistedCloudBackupState::Disabling(_) + )); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); + manager.debug_reset_cloud_backup_state(); +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_retries_stale_uploading_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_uploading_blob_state(metadata.id.clone(), 1); + + manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::UploadedPendingConfirmation(_) + | PersistedCloudBlobState::Confirmed(_), + .. + }) + )); + clear_wallet_upload_runtime_for_test_async(&manager).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_recovers_stale_uploading_state_while_offline() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_uploading_blob_state(metadata.id.clone(), 1); + CONNECTIVITY_MANAGER.set_connection_state(false); + + let error = manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap_err(); + + assert!(matches!(error, CloudBackupError::Deferred(_))); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn upload_wallet_if_dirty_skips_fresh_uploading_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let metadata = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![metadata.clone()]); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + persist_uploading_blob_state( + metadata.id.clone(), + crate::manager::cloud_backup_manager::current_timestamp(), + ); + + manager.do_upload_wallet_if_dirty(&metadata.id).await.unwrap(); + + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 0); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { + state: PersistedCloudBlobState::Uploading(CloudBlobUploadingState { .. }), + .. + }) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn backup_wallets_preserves_newer_dirty_state() { + let _guard = async_test_lock().lock().await; + cove_tokio::init(); + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 0); + + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = WalletType::WatchOnly; + Database::global() + .wallets() + .save_all_wallets(metadata.network, metadata.wallet_mode, vec![metadata.clone()]) + .unwrap(); + globals.cloud.change_wallet_on_next_upload(metadata.id.clone()); + + manager.do_backup_wallets(&[metadata.clone()]).await.unwrap(); + + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + assert_eq!(globals.cloud.uploaded_wallet_backup_count(), 1); + assert!(matches!( + Database::global().cloud_blob_sync_states.get(&record_id).unwrap(), + Some(PersistedCloudBlobSyncState { state: PersistedCloudBlobState::Dirty(_), .. }) + )); +} diff --git a/rust/src/manager/cloud_backup_manager/pending.rs b/rust/src/manager/cloud_backup_manager/pending.rs index e1e396078..0334eabea 100644 --- a/rust/src/manager/cloud_backup_manager/pending.rs +++ b/rust/src/manager/cloud_backup_manager/pending.rs @@ -5,14 +5,13 @@ use std::time::Duration; use act_zero::send; use backon::{BackoffBuilder as _, FibonacciBuilder}; -use cove_util::ResultExt as _; use self::queue_processor::PendingUploadVerifier; use super::{CloudBackupError, PendingUploadVerificationState, RustCloudBackupManager}; use crate::database::Database; use crate::database::cloud_backup::{ - CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, CloudBlobFailureIssue, - CloudBlobUploadedPendingConfirmationState, PersistedCloudBlobState, + CloudBackupRecordKey, CloudBlobDirtyState, CloudBlobFailedState, + CloudBlobUploadedPendingConfirmationState, CloudStorageIssue, PersistedCloudBlobState, PersistedCloudBlobSyncState, }; @@ -21,13 +20,6 @@ pub(crate) use detail::remote_wallet_revision_matches; pub(crate) const MASTER_KEY_UPLOAD_CONFIRMATION_GRACE: Duration = Duration::from_secs(60); pub(crate) const MAX_PENDING_UPLOAD_VERIFICATION_DELAY: Duration = Duration::from_secs(10); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PendingUploadVerificationStatus { - Idle, - Pending, - BlockedOnAuthorization, -} - pub(crate) fn build_pending_upload_backoff() -> backon::FibonacciBackoff { FibonacciBuilder::default() .with_max_delay(MAX_PENDING_UPLOAD_VERIFICATION_DELAY) @@ -47,7 +39,7 @@ impl RustCloudBackupManager { Database::global() .cloud_blob_sync_states .set_if_current(current_state, &next_sync_state) - .map_err_prefix(error_context, CloudBackupError::Internal) + .map_err(|source| CloudBackupError::internal_context(error_context, source)) } pub(crate) fn mark_blob_uploaded_pending_confirmation( @@ -72,10 +64,9 @@ impl RustCloudBackupManager { state, ); - Database::global() - .cloud_blob_sync_states - .set(&sync_state) - .map_err_prefix("persist uploaded cloud blob state", CloudBackupError::Internal)?; + Database::global().cloud_blob_sync_states.set(&sync_state).map_err(|source| { + CloudBackupError::internal_context("persist uploaded cloud blob state", source) + })?; if starts_master_key_grace { send!( @@ -133,7 +124,7 @@ impl RustCloudBackupManager { current_state: &PersistedCloudBlobSyncState, revision_hash: Option, retryable: bool, - issue: Option, + issue: Option, error: String, ) -> Result { let failed_at = crate::manager::cloud_backup_manager::current_timestamp(); @@ -159,10 +150,9 @@ impl RustCloudBackupManager { let dirty_state = current_state .with_state(PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at })); - Database::global() - .cloud_blob_sync_states - .set(&dirty_state) - .map_err_prefix("persist dirty cloud blob state", CloudBackupError::Internal) + Database::global().cloud_blob_sync_states.set(&dirty_state).map_err(|source| { + CloudBackupError::internal_context("persist dirty cloud blob state", source) + }) } pub(crate) fn remove_blob_sync_states(&self, record_ids: I) -> Result<(), CloudBackupError> @@ -172,9 +162,9 @@ impl RustCloudBackupManager { let table = &Database::global().cloud_blob_sync_states; for record_id in record_ids { - table - .delete(&record_id) - .map_err_prefix("remove cloud blob sync state", CloudBackupError::Internal)?; + table.delete(&record_id).map_err(|source| { + CloudBackupError::internal_context("remove cloud blob sync state", source) + })?; } self.refresh_pending_upload_verification_state(); @@ -187,7 +177,7 @@ impl RustCloudBackupManager { send!(self.supervisor.ensure_pending_upload_verification_loop()); } - pub(crate) async fn verify_pending_uploads_once(&self) -> PendingUploadVerificationStatus { + pub(crate) async fn verify_pending_uploads_once(&self) -> PendingUploadVerificationState { PendingUploadVerifier(self.clone()).run_once().await } diff --git a/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs b/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs index 7101a50db..b57aefd89 100644 --- a/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs +++ b/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs @@ -10,11 +10,11 @@ use cove_device::keychain::Keychain; use tracing::{error, info, warn}; use zeroize::Zeroizing; -use super::{MASTER_KEY_UPLOAD_CONFIRMATION_GRACE, PendingUploadVerificationStatus}; +use super::MASTER_KEY_UPLOAD_CONFIRMATION_GRACE; use crate::database::Database; use crate::database::cloud_backup::{ CloudBackupRecordKey, CloudBlobConfirmedState, CloudBlobDirtyState, CloudBlobFailedState, - CloudBlobFailureIssue, CloudBlobUploadedPendingConfirmationState, PersistedCloudBlobState, + CloudBlobUploadedPendingConfirmationState, CloudStorageIssue, PersistedCloudBlobState, PersistedCloudBlobSyncState, }; use crate::manager::cloud_backup_manager::wallets::WalletBackupReader; @@ -28,7 +28,7 @@ enum BlobCheckResult { NotYetUploaded, Stale(String), AuthorizationRequired { error: String }, - Failed { error: String, retryable: bool, issue: Option }, + Failed { error: String, retryable: bool, issue: Option }, } impl BlobCheckResult { @@ -48,7 +48,7 @@ impl BlobCheckResult { | CloudStorageError::DownloadFailed(_) ); - let issue = RustCloudBackupManager::cloud_blob_failure_issue(error.clone().into()); + let issue = RustCloudBackupManager::persistable_cloud_storage_issue(error.clone().into()); Self::Failed { error: error.to_string(), retryable, issue } } @@ -70,13 +70,13 @@ const MAX_PENDING_WALLET_UPLOAD_CONFIRMATION_ATTEMPTS: u32 = 3; pub(crate) struct PendingUploadVerifier(pub(crate) RustCloudBackupManager); impl PendingUploadVerifier { - pub(crate) async fn run_once(&self) -> PendingUploadVerificationStatus { + pub(crate) async fn run_once(&self) -> PendingUploadVerificationState { let table = &Database::global().cloud_blob_sync_states; let states = match table.list() { Ok(states) => states, Err(error) => { error!("Pending upload verification: failed to read sync states: {error}"); - return PendingUploadVerificationStatus::Pending; + return PendingUploadVerificationState::Confirming; } }; @@ -107,7 +107,7 @@ impl PendingUploadVerifier { Ok(persisted) => persisted, Err(error) => { error!("Pending upload verification: failed to persist state: {error}"); - return PendingUploadVerificationStatus::Pending; + return PendingUploadVerificationState::Confirming; } }; @@ -151,12 +151,12 @@ impl PendingUploadVerifier { match outcome { PendingUploadRunOutcome::BlockedOnAuthorization => { - PendingUploadVerificationStatus::BlockedOnAuthorization + PendingUploadVerificationState::BlockedOnAuthorization } - PendingUploadRunOutcome::StillPending => PendingUploadVerificationStatus::Pending, + PendingUploadRunOutcome::StillPending => PendingUploadVerificationState::Confirming, PendingUploadRunOutcome::Idle | PendingUploadRunOutcome::Confirmed - | PendingUploadRunOutcome::Failed => PendingUploadVerificationStatus::Idle, + | PendingUploadRunOutcome::Failed => PendingUploadVerificationState::Idle, } } diff --git a/rust/src/manager/cloud_backup_manager/pending_verification.rs b/rust/src/manager/cloud_backup_manager/pending_verification.rs index 3855d59f2..4ef926471 100644 --- a/rust/src/manager/cloud_backup_manager/pending_verification.rs +++ b/rust/src/manager/cloud_backup_manager/pending_verification.rs @@ -5,21 +5,10 @@ use crate::database::cloud_backup::{ PersistedPendingVerificationCompletion, PersistedPendingVerificationUpload, }; -use super::DeepVerificationReport; - -#[derive(Debug, Clone)] -pub(crate) struct PendingVerificationCompletion { - pub(crate) report: DeepVerificationReport, - pub(crate) namespace_id: String, - pub(crate) uploads: Vec, - pub(crate) created_at: Option, -} +use super::{DeepVerificationReport, current_timestamp}; -#[derive(Debug, Clone)] -pub(crate) enum PendingVerificationUpload { - MasterKeyWrapper, - Wallet { record_id: String, expected_revision: String }, -} +pub(crate) type PendingVerificationCompletion = PersistedPendingVerificationCompletion; +pub(crate) type PendingVerificationUpload = PersistedPendingVerificationUpload; impl PendingVerificationCompletion { pub(crate) fn new( @@ -28,15 +17,15 @@ impl PendingVerificationCompletion { uploads: Vec, ) -> Self { Self { - report, + report: PersistedDeepVerificationReport::from_deep_verification_report(&report), namespace_id, uploads, - created_at: Some(crate::manager::cloud_backup_manager::current_timestamp()), + created_at: Some(current_timestamp()), } } - pub(crate) fn report(&self) -> &DeepVerificationReport { - &self.report + pub(crate) fn report(&self) -> DeepVerificationReport { + self.report.to_deep_verification_report() } pub(crate) fn namespace_id(&self) -> &str { @@ -59,33 +48,6 @@ impl PendingVerificationCompletion { now.saturating_sub(created_at) >= ttl_seconds } - - pub(crate) fn persisted(&self) -> PersistedPendingVerificationCompletion { - PersistedPendingVerificationCompletion { - report: PersistedDeepVerificationReport::from(&self.report), - namespace_id: self.namespace_id.clone(), - created_at: self.created_at, - uploads: self - .uploads - .iter() - .cloned() - .map(PersistedPendingVerificationUpload::from) - .collect(), - } - } - - pub(crate) fn from_persisted(completion: PersistedPendingVerificationCompletion) -> Self { - Self { - report: DeepVerificationReport::from(completion.report), - namespace_id: completion.namespace_id, - created_at: completion.created_at, - uploads: completion - .uploads - .into_iter() - .map(PendingVerificationUpload::from_persisted) - .collect(), - } - } } impl PendingVerificationUpload { @@ -135,33 +97,10 @@ impl PendingVerificationUpload { .unwrap_or(expected_revision) .to_owned() } - - fn from_persisted(upload: PersistedPendingVerificationUpload) -> Self { - match upload { - PersistedPendingVerificationUpload::MasterKeyWrapper => Self::MasterKeyWrapper, - PersistedPendingVerificationUpload::Wallet { record_id, expected_revision } => { - Self::Wallet { record_id, expected_revision } - } - } - } -} - -impl DeepVerificationReport { - fn from(report: PersistedDeepVerificationReport) -> Self { - Self { - master_key_wrapper_repaired: report.master_key_wrapper_repaired, - local_master_key_repaired: report.local_master_key_repaired, - credential_recovered: report.credential_recovered, - wallets_verified: report.wallets_verified, - wallets_failed: report.wallets_failed, - wallets_unsupported: report.wallets_unsupported, - detail: None, - } - } } -impl From<&DeepVerificationReport> for PersistedDeepVerificationReport { - fn from(report: &DeepVerificationReport) -> Self { +impl PersistedDeepVerificationReport { + fn from_deep_verification_report(report: &DeepVerificationReport) -> Self { Self { master_key_wrapper_repaired: report.master_key_wrapper_repaired, local_master_key_repaired: report.local_master_key_repaired, @@ -171,15 +110,16 @@ impl From<&DeepVerificationReport> for PersistedDeepVerificationReport { wallets_unsupported: report.wallets_unsupported, } } -} -impl From for PersistedPendingVerificationUpload { - fn from(upload: PendingVerificationUpload) -> Self { - match upload { - PendingVerificationUpload::MasterKeyWrapper => Self::MasterKeyWrapper, - PendingVerificationUpload::Wallet { record_id, expected_revision } => { - Self::Wallet { record_id, expected_revision } - } + fn to_deep_verification_report(&self) -> DeepVerificationReport { + DeepVerificationReport { + master_key_wrapper_repaired: self.master_key_wrapper_repaired, + local_master_key_repaired: self.local_master_key_repaired, + credential_recovered: self.credential_recovered, + wallets_verified: self.wallets_verified, + wallets_failed: self.wallets_failed, + wallets_unsupported: self.wallets_unsupported, + detail: None, } } } diff --git a/rust/src/manager/cloud_backup_manager/reconcile.rs b/rust/src/manager/cloud_backup_manager/reconcile.rs index 9e590bf4c..d1488bce3 100644 --- a/rust/src/manager/cloud_backup_manager/reconcile.rs +++ b/rust/src/manager/cloud_backup_manager/reconcile.rs @@ -1,5 +1,6 @@ use cove_device::cloud_storage::CloudSyncHealth; -use tracing::error; + +use crate::manager::deferred_sender::SingleOrMany; use super::model::{CloudBackupStateReducerEffects, CloudBackupStateReducerEvent}; use super::verify::coordinator::{ @@ -7,8 +8,7 @@ use super::verify::coordinator::{ }; use super::{ CloudBackupDetailOutcome, CloudBackupEnableContext, CloudBackupLifecycle, - CloudBackupRecoveryOutcome, CloudBackupSettingsRowStatus, CloudBackupStatus, - CloudBackupVerificationMetadata, CloudBackupVerificationOutcome, + CloudBackupSettingsRowStatus, CloudBackupStatus, CloudBackupVerificationMetadata, CloudBackupVerificationPresentation, CloudBackupVerificationSource, PendingUploadVerificationState, RustCloudBackupManager, }; @@ -34,9 +34,7 @@ impl RustCloudBackupManager { } fn send(&self, message: Message) { - if let Err(error) = self.reconciler.send(message) { - error!("unable to send cloud backup message: {error:?}"); - } + self.reconciler.send_sync(message); } pub(crate) fn apply_model_event(&self, event: CloudBackupStateReducerEvent) -> bool { @@ -115,13 +113,11 @@ impl RustCloudBackupManager { } if let Some(verification) = effect.verification { - self.apply_verification_outcome(CloudBackupVerificationOutcome::from_state( - verification, - )); + self.apply_verification_state(verification); } if let Some(recovery) = effect.recovery { - self.apply_recovery_outcome(CloudBackupRecoveryOutcome::from_state(recovery)); + self.apply_recovery_state(recovery); } if effect.refresh_sync_health { @@ -206,11 +202,12 @@ impl RustCloudBackupManager { #[uniffi::export] impl RustCloudBackupManager { pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - reconciler.reconcile(field); + self.reconciler.listen(move |field| match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => { + for message in messages { + reconciler.reconcile(message); + } } }); } diff --git a/rust/src/manager/cloud_backup_manager/remote_inventory.rs b/rust/src/manager/cloud_backup_manager/remote_inventory.rs index 8cfdcc5d1..db94a4840 100644 --- a/rust/src/manager/cloud_backup_manager/remote_inventory.rs +++ b/rust/src/manager/cloud_backup_manager/remote_inventory.rs @@ -1,7 +1,6 @@ use cove_cspp::backup_data::wallet_record_id; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient}; use cove_device::keychain::Keychain; -use cove_util::ResultExt as _; use futures::stream::{self, StreamExt as _}; use tracing::warn; use zeroize::Zeroizing; @@ -40,9 +39,9 @@ impl RustCloudBackupManager { let db = Database::global(); let local_wallets = CloudBackupStore::new(&db).all_wallets()?; let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - let Some(master_key) = cspp - .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + let Some(master_key) = cspp.load_master_key_from_store().map_err(|source| { + CloudBackupError::internal_context("load local master key", source) + })? else { return Ok(RemoteWalletTruth { unknown_record_ids: wallet_record_ids.iter().cloned().collect(), diff --git a/rust/src/manager/cloud_backup_manager/store.rs b/rust/src/manager/cloud_backup_manager/store.rs index 73be8c852..a6e66ec5d 100644 --- a/rust/src/manager/cloud_backup_manager/store.rs +++ b/rust/src/manager/cloud_backup_manager/store.rs @@ -4,7 +4,6 @@ use cove_cspp::backup_data::remote_payload::RemotePayloadMetadata; use cove_cspp::wallet_crypto; use cove_device::cloud_storage::CloudStorageClient; use cove_types::network::Network; -use cove_util::ResultExt as _; use futures::stream::{self, StreamExt as _, TryStreamExt as _}; use strum::IntoEnumIterator as _; @@ -42,7 +41,9 @@ impl CloudBackupStore { crate::manager::cloud_backup_manager::current_timestamp(), wallet_count, )) - .map_err_prefix("persist cloud backup state", CloudBackupError::Internal) + .map_err(|source| { + CloudBackupError::internal_context("persist cloud backup state", source) + }) } pub(crate) fn last_sync(&self) -> Option { @@ -53,7 +54,9 @@ impl CloudBackupStore { pub(crate) fn all_wallets(&self) -> Result, CloudBackupError> { all_local_wallets_from(|network, mode| { self.0.wallets.get_all(network, mode).map_err(|error| { - CloudBackupError::Internal(format!("read wallets for {network}/{mode}: {error}")) + CloudBackupError::Internal( + format!("read wallets for {network}/{mode}: {error}").into(), + ) }) }) } @@ -84,10 +87,9 @@ impl CloudBackupStore { critical_key, remote_metadata, ) - .map_err_str(CloudBackupError::Crypto)?; + .map_err(CloudBackupError::crypto)?; - let wallet_json = - serde_json::to_vec(&encrypted).map_err_str(CloudBackupError::Internal)?; + let wallet_json = serde_json::to_vec(&encrypted).map_err(CloudBackupError::internal)?; writes .upload_wallet_backup( @@ -128,7 +130,9 @@ impl CloudBackupStore { self.0 .cloud_blob_sync_states .list() - .map_err_prefix("list cloud blob sync states", CloudBackupError::Internal) + .map_err(|source| { + CloudBackupError::internal_context("list cloud blob sync states", source) + }) .map(|states| { states .into_iter() @@ -142,15 +146,15 @@ impl CloudBackupStore { wallet_count: u32, ) -> Result<(), CloudBackupError> { let now = crate::manager::cloud_backup_manager::current_timestamp(); - let current = self - .0 - .cloud_backup_state - .get() - .map_err_prefix("read cloud backup state", CloudBackupError::Internal)?; + let current = self.0.cloud_backup_state.get().map_err(|source| { + CloudBackupError::internal_context("read cloud backup state", source) + })?; self.0 .cloud_backup_state .set(¤t.mark_enabled_preserving_verification(now, wallet_count)) - .map_err_prefix("persist cloud backup state", CloudBackupError::Internal) + .map_err(|source| { + CloudBackupError::internal_context("persist cloud backup state", source) + }) } } diff --git a/rust/src/manager/cloud_backup_manager/sync_health.rs b/rust/src/manager/cloud_backup_manager/sync_health.rs index f09895196..b40aa5ba5 100644 --- a/rust/src/manager/cloud_backup_manager/sync_health.rs +++ b/rust/src/manager/cloud_backup_manager/sync_health.rs @@ -10,7 +10,7 @@ use super::{ }; use crate::database::Database; use crate::database::cloud_backup::{ - CloudBlobFailureIssue, PersistedCloudBlobState, PersistedCloudBlobSyncState, + CloudStorageIssue, PersistedCloudBlobState, PersistedCloudBlobSyncState, }; pub(crate) const SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE: &str = @@ -140,7 +140,7 @@ impl RustCloudBackupManager { return None; }; - if failed_state.issue == Some(CloudBlobFailureIssue::AuthorizationRequired) { + if failed_state.issue == Some(CloudStorageIssue::AuthorizationRequired) { return Some(CloudSyncHealth::AuthorizationRequired(sync_health_failed_message( sync_state, failed_state, @@ -270,7 +270,7 @@ mod tests { revision_hash: None, retryable: true, error: "authorization required".into(), - issue: Some(CloudBlobFailureIssue::AuthorizationRequired), + issue: Some(CloudStorageIssue::AuthorizationRequired), failed_at: 2, }), ); diff --git a/rust/src/manager/cloud_backup_manager/verify.rs b/rust/src/manager/cloud_backup_manager/verify.rs index a8dccc91d..debbb2795 100644 --- a/rust/src/manager/cloud_backup_manager/verify.rs +++ b/rust/src/manager/cloud_backup_manager/verify.rs @@ -11,7 +11,6 @@ use cove_cspp::master_key_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_device::keychain::Keychain; use cove_device::passkey::PasskeyAccess; -use cove_util::ResultExt as _; use tracing::{error, info, warn}; use zeroize::Zeroizing; @@ -31,9 +30,9 @@ use self::wrapper_repair::{WrapperRepairOperation, WrapperRepairStrategy}; use super::CloudBackupStore; use super::{ BlockingCloudStep, CloudBackupDetailResult, CloudBackupError, CloudBackupKeychain, - CloudBackupRetryAction, CloudBackupRetryContext, CloudBackupStatus, DeepVerificationFailure, - DeepVerificationReport, DeepVerificationResult, PendingVerificationCompletion, - PendingVerificationUpload, RustCloudBackupManager, is_connectivity_related_issue, + CloudBackupRetryAction, CloudBackupStatus, DeepVerificationFailure, DeepVerificationReport, + DeepVerificationResult, PendingVerificationCompletion, PendingVerificationUpload, + RustCloudBackupManager, is_connectivity_related_issue, }; use crate::database::Database; use crate::database::cloud_backup::{ @@ -81,19 +80,18 @@ impl RustCloudBackupManager { error: CloudBackupError, ) -> DeepVerificationResult { error!("Deep verification unexpected error: {error}"); - let retry_context = is_connectivity_related_issue(&error).then(|| { - let action = if force_discoverable { + let retry_action = is_connectivity_related_issue(&error).then_some({ + if force_discoverable { CloudBackupRetryAction::VerifyDiscoverable } else { CloudBackupRetryAction::Verify - }; - CloudBackupRetryContext::connectivity(action) + } }); DeepVerificationResult::Failed(DeepVerificationFailure::retry( error.to_string(), None, - retry_context, + retry_action, )) } @@ -161,7 +159,7 @@ impl RustCloudBackupManager { "wallet upload failed: {source}; persist partial wallet upload batch failed: {finalize_error}" ); - CloudBackupError::Internal(message) + CloudBackupError::Internal(message.into()) })?; return Err(source); @@ -267,7 +265,7 @@ impl RustCloudBackupManager { let local_master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)? + .map_err(|source| CloudBackupError::internal_context("load local master key", source))? .ok_or_else(|| CloudBackupError::Internal("no local master key".into()))?; let wallet_record_ids = match cloud.list_wallet_backups(namespace.clone()).await { @@ -418,9 +416,9 @@ impl RustCloudBackupManager { }; let encrypted: EncryptedMasterKeyBackup = - serde_json::from_slice(&master_json).map_err_str(CloudBackupError::Internal)?; + serde_json::from_slice(&master_json).map_err(CloudBackupError::internal)?; encrypted.remote_metadata.normalized_master_key(namespace).map_err(|error| { - CloudBackupError::Internal(format!("normalize master key payload: {error}")) + CloudBackupError::Internal(format!("normalize master key payload: {error}").into()) })?; match encrypted.backup_version() { Ok(MasterKeyBackupVersion::V1) => {} @@ -460,9 +458,12 @@ impl RustCloudBackupManager { CloudBackupKeychain::new(keychain.clone()) .save_passkey(&authenticated.credential_id, encrypted.prf_salt) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal)?; - cspp.save_master_key(&master_key) - .map_err_prefix("save recovered master key", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("save cspp credentials", source) + })?; + cspp.save_master_key(&master_key).map_err(|source| { + CloudBackupError::internal_context("save recovered master key", source) + })?; info!("Recovered local master key from cloud"); Ok(master_key) diff --git a/rust/src/manager/cloud_backup_manager/verify/integrity.rs b/rust/src/manager/cloud_backup_manager/verify/integrity.rs index 505c5d9c1..3f6a8caea 100644 --- a/rust/src/manager/cloud_backup_manager/verify/integrity.rs +++ b/rust/src/manager/cloud_backup_manager/verify/integrity.rs @@ -53,7 +53,7 @@ impl RustCloudBackupManager { /// /// Verifies the master key is in the keychain and backup files exist in iCloud /// Returns None if everything is OK, Some(warning) if there's a problem - pub async fn verify_backup_integrity_impl(&self) -> Option { + pub(crate) async fn verify_backup_integrity_impl(&self) -> Option { let state = self.state.read().status().clone(); if !matches!(state, CloudBackupStatus::Enabled | CloudBackupStatus::PasskeyMissing) { return None; diff --git a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs index 7a97b2347..cea6e5ffc 100644 --- a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs +++ b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs @@ -224,7 +224,7 @@ fn map_discovery_error(error: PasskeyError) -> Result Ok(PasskeyAuthOutcome::UserCancelled), PasskeyError::NoCredentialFound => Ok(PasskeyAuthOutcome::NoCredentialFound), PasskeyError::PrfUnsupportedProvider => Err(CloudBackupError::UnsupportedPasskeyProvider), - other => Err(CloudBackupError::Passkey(other.to_string())), + other => Err(CloudBackupError::passkey(other)), } } diff --git a/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs b/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs index ba64af5d5..095214014 100644 --- a/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs +++ b/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs @@ -1,7 +1,6 @@ use ahash::HashMap; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_device::keychain::Keychain; -use cove_util::ResultExt as _; use tracing::{error, warn}; use zeroize::Zeroizing; @@ -169,7 +168,7 @@ impl RustCloudBackupManager { let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); let master_key = cspp .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal) + .map_err(|source| CloudBackupError::internal_context("load local master key", source)) .map_err(|error| { Box::new(self.pending_verification_failure(&completion, error.to_string())) })? @@ -180,7 +179,7 @@ impl RustCloudBackupManager { })?; let critical_key = Zeroizing::new(master_key.critical_data_key()); - let mut report = completion.report().clone(); + let mut report = completion.report(); let sync_states_by_record_id: HashMap<_, _> = Database::global() .cloud_blob_sync_states .list() diff --git a/rust/src/manager/cloud_backup_manager/verify/session.rs b/rust/src/manager/cloud_backup_manager/verify/session.rs index 4644a7182..b86768886 100644 --- a/rust/src/manager/cloud_backup_manager/verify/session.rs +++ b/rust/src/manager/cloud_backup_manager/verify/session.rs @@ -14,7 +14,6 @@ use cove_cspp::master_key_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient, CloudStorageError}; use cove_device::keychain::Keychain; use cove_device::passkey::{PasskeyAccess, PasskeyCredentialPresence}; -use cove_util::ResultExt as _; use futures::stream::{self, StreamExt as _}; use tracing::{debug, info, warn}; use zeroize::Zeroizing; @@ -28,10 +27,9 @@ use crate::database::Database; use crate::manager::cloud_backup_manager::pending::remote_wallet_revision_matches; use crate::manager::cloud_backup_manager::{ BlockingCloudStep, CLOUD_BACKUP_IO_CONCURRENCY, CloudBackupDetail, CloudBackupError, - CloudBackupKeychain, CloudBackupOtherBackupsState, CloudBackupRetryAction, - CloudBackupRetryContext, CloudBackupStore, DeepVerificationFailure, DeepVerificationReport, - DeepVerificationResult, PASSKEY_RP_ID, PendingVerificationCompletion, - PendingVerificationUpload, RustCloudBackupManager, + CloudBackupKeychain, CloudBackupOtherBackupsState, CloudBackupRetryAction, CloudBackupStore, + DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, PASSKEY_RP_ID, + PendingVerificationCompletion, PendingVerificationUpload, RustCloudBackupManager, actors::CloudBackupUploadedWallet, blocking_cloud_error, cloud_inventory::{CloudWalletInventory, RemoteWalletTruth}, @@ -315,9 +313,9 @@ impl VerificationSession { let keychain = Keychain::global().clone(); let cloud_keychain = CloudBackupKeychain::new(keychain.clone()); let cspp = cove_cspp::Cspp::new(keychain.clone()); - let local_master_key = cspp - .load_master_key_from_store() - .map_err_prefix("load local master key", CloudBackupError::Internal)?; + let local_master_key = cspp.load_master_key_from_store().map_err(|source| { + CloudBackupError::internal_context("load local master key", source) + })?; Ok(Self { manager: manager.clone(), @@ -447,10 +445,12 @@ impl VerificationSession { match self.cloud.download_master_key_backup(self.namespace.clone()).await { Ok(json) => { let encrypted: EncryptedMasterKeyBackup = - serde_json::from_slice(&json).map_err_str(CloudBackupError::Internal)?; + serde_json::from_slice(&json).map_err(CloudBackupError::internal)?; encrypted.remote_metadata.normalized_master_key(&self.namespace).map_err( |error| { - CloudBackupError::Internal(format!("normalize master key payload: {error}")) + CloudBackupError::Internal( + format!("normalize master key payload: {error}").into(), + ) }, )?; @@ -563,18 +563,18 @@ impl VerificationSession { let repaired = match &self.local_master_key { // restore the missing local key from the verified cloud backup None => { - self.cspp - .save_master_key(master_key) - .map_err_prefix("repair local master key", CloudBackupError::Internal)?; + self.cspp.save_master_key(master_key).map_err(|source| { + CloudBackupError::internal_context("repair local master key", source) + })?; info!("Repaired local master key from cloud"); true } // replace a stale local key after cloud decryption proves the cloud key is valid Some(local_key) if local_key.as_bytes() != master_key.as_bytes() => { - self.cspp - .save_master_key(master_key) - .map_err_prefix("repair local master key", CloudBackupError::Internal)?; + self.cspp.save_master_key(master_key).map_err(|source| { + CloudBackupError::internal_context("repair local master key", source) + })?; info!("Repaired local master key to match cloud"); true } @@ -943,23 +943,21 @@ impl VerificationSession { fn retry_result_with_context( &self, message: impl Into, - retry_context: Option, + retry_action: Option, ) -> DeepVerificationResult { DeepVerificationResult::Failed(DeepVerificationFailure::retry( message, self.detail(), - retry_context, + retry_action, )) } - fn connectivity_retry_context(&self) -> CloudBackupRetryContext { - let action = if self.force_discoverable { + fn connectivity_retry_action(&self) -> CloudBackupRetryAction { + if self.force_discoverable { CloudBackupRetryAction::VerifyDiscoverable } else { CloudBackupRetryAction::Verify - }; - - CloudBackupRetryContext::connectivity(action) + } } fn cloud_storage_retry_result( @@ -970,10 +968,10 @@ impl VerificationSession { let error = CloudBackupError::cloud_storage_context(context, error); let error = blocking_cloud_error(BlockingCloudStep::Verify, error); - let retry_context = - is_connectivity_related_issue(&error).then(|| self.connectivity_retry_context()); + let retry_action = + is_connectivity_related_issue(&error).then_some(self.connectivity_retry_action()); - self.retry_result_with_context(error.to_string(), retry_context) + self.retry_result_with_context(error.to_string(), retry_action) } fn cloud_backup_retry_result( @@ -984,7 +982,7 @@ impl VerificationSession { if is_connectivity_related_issue(error) { return self.retry_result_with_context( offline_error_for_step(BlockingCloudStep::Verify).to_string(), - Some(self.connectivity_retry_context()), + Some(self.connectivity_retry_action()), ); } diff --git a/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs b/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs index 0cb4e84b5..38a212ede 100644 --- a/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs +++ b/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs @@ -4,7 +4,6 @@ use cove_cspp::master_key_crypto; use cove_device::cloud_storage::CloudStorageClient; use cove_device::passkey::PasskeyAccess; use cove_tokio::unblock; -use cove_util::ResultExt as _; use rand::RngExt as _; use tracing::info; use zeroize::Zeroizing; @@ -40,7 +39,7 @@ impl From for CloudBackupError { let msg = error.to_string(); match error { - WrapperRepairError::WrongKey => CloudBackupError::Crypto(msg), + WrapperRepairError::WrongKey => CloudBackupError::Crypto(msg.into()), WrapperRepairError::Inconclusive => CloudBackupError::Cloud(msg), WrapperRepairError::Operation(error) => error, } @@ -239,10 +238,10 @@ impl WrapperRepairOperation { credentials.provider_hint.clone(), RemotePayloadMetadata::master_key(&self.namespace, uploaded_at), ) - .map_err_str(CloudBackupError::Crypto)?; + .map_err(CloudBackupError::crypto)?; let backup_json = - serde_json::to_vec(&encrypted_backup).map_err_str(CloudBackupError::Internal)?; + serde_json::to_vec(&encrypted_backup).map_err(CloudBackupError::internal)?; let master_key_wrapper_revision = master_key_wrapper_revision_hash(&backup_json); Ok(CloudBackupPreparedPasskeyWrapperRepair { @@ -320,7 +319,7 @@ impl WrapperRepairOperation { ) }) .await - .map_err_str(CloudBackupError::Passkey)?; + .map_err(CloudBackupError::passkey)?; let prf_key: [u8; 32] = prf_output .try_into() diff --git a/rust/src/manager/cloud_backup_manager/wallet_changes.rs b/rust/src/manager/cloud_backup_manager/wallet_changes.rs index 55e548408..736953e55 100644 --- a/rust/src/manager/cloud_backup_manager/wallet_changes.rs +++ b/rust/src/manager/cloud_backup_manager/wallet_changes.rs @@ -2,7 +2,6 @@ use std::time::Duration; use act_zero::send; use cove_cspp::backup_data::wallet_record_id; -use cove_util::ResultExt as _; use tracing::{error, warn}; use super::wallets::wallet_metadata_change_requires_upload; @@ -81,10 +80,9 @@ impl RustCloudBackupManager { PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at }), ); - Database::global() - .cloud_blob_sync_states - .set(&sync_state) - .map_err_prefix("persist dirty cloud backup state", CloudBackupError::Internal)?; + Database::global().cloud_blob_sync_states.set(&sync_state).map_err(|source| { + CloudBackupError::internal_context("persist dirty cloud backup state", source) + })?; } self.refresh_sync_health(); diff --git a/rust/src/manager/cloud_backup_manager/wallets/passkey.rs b/rust/src/manager/cloud_backup_manager/wallets/passkey.rs index 9bb763ddc..85cbddbc9 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/passkey.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/passkey.rs @@ -28,14 +28,14 @@ pub(crate) async fn delay_before_new_passkey_auth() { tokio::time::sleep(delay).await; } -pub struct NamespaceMatch { - pub namespace_id: String, - pub master_key: cove_cspp::master_key::MasterKey, - pub prf_salt: [u8; 32], - pub credential_id: Vec, +pub(crate) struct NamespaceMatch { + pub(crate) namespace_id: String, + pub(crate) master_key: cove_cspp::master_key::MasterKey, + pub(crate) prf_salt: [u8; 32], + pub(crate) credential_id: Vec, } -pub enum NamespaceMatchOutcome { +pub(crate) enum NamespaceMatchOutcome { Matched(Vec), UserDeclined, NoMatch, @@ -44,12 +44,12 @@ pub enum NamespaceMatchOutcome { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PasskeyMaterialPurpose { +pub(crate) enum PasskeyMaterialPurpose { EnableCloudBackup, RepairWrapper, } -pub enum PasskeyMaterialOutcome { +pub(crate) enum PasskeyMaterialOutcome { Authenticated(UnpersistedPrfKey), RegisteredForConfirmation(StagedPrfKey), } @@ -105,24 +105,26 @@ impl PasskeyMaterialPurpose { } /// Acquires passkey PRF material without persisting it to the keychain -pub struct PasskeyMaterialAcquirer { +pub(crate) struct PasskeyMaterialAcquirer { passkey: PasskeyAccess, } impl PasskeyMaterialAcquirer { /// Builds an acquirer from the passkey service handle - pub fn new(passkey: &PasskeyAccess) -> Self { + pub(crate) fn new(passkey: &PasskeyAccess) -> Self { Self { passkey: passkey.clone() } } /// Creates a passkey for wrapper repair without persisting keychain state - pub async fn create_for_wrapper_repair(&self) -> Result { + pub(crate) async fn create_for_wrapper_repair( + &self, + ) -> Result { info!("Creating new passkey for wrapper repair"); self.create_new_prf_key_with_mapper(map_wrapper_repair_passkey_error).await } /// Registers an enable passkey without immediately authenticating it - pub async fn register_for_enable(&self) -> Result { + pub(crate) async fn register_for_enable(&self) -> Result { info!("Registering new passkey for cloud backup enable"); let prf_salt: [u8; 32] = rand::rng().random(); let (registration, name_suffix) = @@ -136,7 +138,7 @@ impl PasskeyMaterialAcquirer { } /// Confirms a registered enable passkey by acquiring PRF material with targeted auth - pub async fn confirm_registered_for_enable( + pub(crate) async fn confirm_registered_for_enable( &self, staged: &StagedPrfKey, ) -> Result { @@ -157,7 +159,7 @@ impl PasskeyMaterialAcquirer { } /// Discovers an existing passkey for wrapper repair or creates a new one - pub async fn discover_or_create_for_wrapper_repair( + pub(crate) async fn discover_or_create_for_wrapper_repair( &self, ) -> Result { match self.acquire(PasskeyMaterialPurpose::RepairWrapper).await? { @@ -171,7 +173,7 @@ impl PasskeyMaterialAcquirer { } /// Discovers an existing enable passkey or registers a new passkey for later confirmation - pub async fn discover_or_register_for_enable( + pub(crate) async fn discover_or_register_for_enable( &self, ) -> Result { self.acquire(PasskeyMaterialPurpose::EnableCloudBackup).await @@ -389,19 +391,19 @@ fn passkey_provider_hint( } /// Matches a discoverable passkey against candidate cloud backup namespaces -pub struct NamespacePasskeyMatcher { +pub(crate) struct NamespacePasskeyMatcher { cloud: CloudStorageClient, passkey: PasskeyAccess, } impl NamespacePasskeyMatcher { /// Builds a matcher from cloud and passkey service handles - pub fn new(cloud: &CloudStorageClient, passkey: &PasskeyAccess) -> Self { + pub(crate) fn new(cloud: &CloudStorageClient, passkey: &PasskeyAccess) -> Self { Self { cloud: cloud.clone(), passkey: passkey.clone() } } /// Downloads candidate wrappers and tries the selected passkey against each PRF salt - pub async fn match_namespaces( + pub(crate) async fn match_namespaces( &self, namespaces: &[String], ) -> Result { @@ -468,7 +470,7 @@ impl NamespacePasskeyMatcher { Err(PasskeyError::PrfUnsupportedProvider) => { return Err(CloudBackupError::UnsupportedPasskeyProvider); } - Err(error) => return Err(CloudBackupError::Passkey(error.to_string())), + Err(error) => return Err(CloudBackupError::passkey(error)), }; let mut matches = Vec::new(); @@ -604,7 +606,7 @@ fn map_wrapper_repair_passkey_error(error: PasskeyError) -> CloudBackupError { error if is_android_passkey_association_error(&error) => { CloudBackupError::Passkey(android_passkey_association_message().into()) } - other => CloudBackupError::Passkey(other.to_string()), + other => CloudBackupError::passkey(other), } } @@ -618,7 +620,7 @@ fn map_enable_passkey_error(error: PasskeyError) -> CloudBackupError { error if is_android_passkey_association_error(&error) => { CloudBackupError::Passkey(android_passkey_association_message().into()) } - other => CloudBackupError::Passkey(other.to_string()), + other => CloudBackupError::passkey(other), } } diff --git a/rust/src/manager/cloud_backup_manager/wallets/payload.rs b/rust/src/manager/cloud_backup_manager/wallets/payload.rs index 4fbb6788a..588c1ce18 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/payload.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/payload.rs @@ -4,7 +4,6 @@ use cove_cspp::backup_data::{ DescriptorPair, WalletEntry, WalletMode, WalletSecret as CloudWalletSecret, }; use cove_device::keychain::Keychain; -use cove_util::ResultExt as _; use serde::Serialize; use sha2::{Digest, Sha256}; use tracing::warn; @@ -88,8 +87,9 @@ impl WalletBackupRevisionPayload { } fn content_revision_hash(&self) -> Result { - let bytes = serde_json::to_vec(self) - .map_err_prefix("serialize revision payload", CloudBackupError::Internal)?; + let bytes = serde_json::to_vec(self).map_err(|source| { + CloudBackupError::internal_context("serialize revision payload", source) + })?; Ok(hex::encode(Sha256::digest(bytes))) } @@ -104,7 +104,7 @@ impl From for WalletMode { } } -pub async fn build_wallet_entry( +async fn build_wallet_entry( metadata: &WalletMetadata, mode: LocalWalletMode, ) -> Result { @@ -114,7 +114,7 @@ pub async fn build_wallet_entry( let secret = match metadata.wallet_type { WalletType::Hot => { let mnemonic = keychain.get_wallet_key(id).map_err(|error| { - CloudBackupError::Internal(format!("failed to get wallet mnemonic: {error}")) + CloudBackupError::Internal(format!("failed to get wallet mnemonic: {error}").into()) })?; let Some(mnemonic) = mnemonic else { return Err(CloudBackupError::Internal("hot wallet has no mnemonic".into())); @@ -130,14 +130,14 @@ pub async fn build_wallet_entry( Ok(Some(xpub)) => Some(xpub.to_string()), Ok(None) => None, Err(error) => { - return Err(CloudBackupError::Internal(format!("failed to read xpub: {error}"))); + return Err(CloudBackupError::Internal(format!("failed to read xpub: {error}").into())); } }; let descriptors = backup_descriptors(keychain, metadata, id, &secret)?; let metadata_value = serde_json::to_value(metadata.clone_without_local_scan_state()) - .map_err_prefix("serialize metadata", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("serialize metadata", source))?; let wallet_mode = mode.into(); @@ -184,14 +184,16 @@ fn backup_descriptors( } Ok(None) => {} Err(error) => { - return Err(CloudBackupError::Internal(format!("failed to read descriptors: {error}"))); + return Err(CloudBackupError::Internal( + format!("failed to read descriptors: {error}").into(), + )); } } match secret { CloudWalletSecret::Mnemonic(mnemonic) => { let mnemonic = bip39::Mnemonic::from_str(mnemonic).map_err(|error| { - CloudBackupError::Internal(format!("failed to parse mnemonic: {error}")) + CloudBackupError::Internal(format!("failed to parse mnemonic: {error}").into()) })?; let descriptors = mnemonic.into_descriptors(None, metadata.network, metadata.address_type); @@ -226,13 +228,13 @@ fn build_cold_wallet_secret( warn!("Tap signer wallet has no backup, exporting without it"); Ok(CloudWalletSecret::WatchOnly) } - Err(error) => { - Err(CloudBackupError::Internal(format!("failed to read tap signer backup: {error}"))) - } + Err(error) => Err(CloudBackupError::Internal( + format!("failed to read tap signer backup: {error}").into(), + )), } } -pub async fn prepare_wallet_backup( +pub(crate) async fn prepare_wallet_backup( metadata: &WalletMetadata, mode: LocalWalletMode, ) -> Result { @@ -256,7 +258,7 @@ fn prepare_cloud_labels(labels_jsonl: &str) -> Result Result bool { @@ -281,7 +283,7 @@ where T: Serialize, { let bytes = serde_json::to_vec(value) - .map_err_prefix("serialize sync field", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("serialize sync field", source))?; Ok(hex::encode(Sha256::digest(bytes))) } @@ -296,24 +298,26 @@ async fn export_wallet_labels_jsonl( wallet_id: &crate::wallet::metadata::WalletId, ) -> Result { let manager = LabelManager::try_new(wallet_id.clone()) - .map_err(|error| CloudBackupError::Internal(format!("open labels db: {error}")))?; + .map_err(|error| CloudBackupError::Internal(format!("open labels db: {error}").into()))?; - manager.export().await.map_err_str(CloudBackupError::Internal) + manager.export().await.map_err(CloudBackupError::internal) } -pub fn decode_cloud_labels_jsonl(entry: &WalletEntry) -> Result, CloudBackupError> { +pub(crate) fn decode_cloud_labels_jsonl( + entry: &WalletEntry, +) -> Result, CloudBackupError> { let Some(compressed_labels) = &entry.labels_zstd_jsonl else { return Ok(None); }; let decompressed = crate::backup::crypto::decompress(compressed_labels) - .map_err_prefix("decompress labels", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("decompress labels", source))?; ensure_cloud_labels_size(decompressed.len(), "decompressed")?; - String::from_utf8(decompressed.to_vec()) - .map(Some) - .map_err(|error| CloudBackupError::Internal(format!("decode labels as utf8: {error}"))) + String::from_utf8(decompressed.to_vec()).map(Some).map_err(|error| { + CloudBackupError::Internal(format!("decode labels as utf8: {error}").into()) + }) } fn ensure_cloud_labels_size(size: usize, label_state: &str) -> Result<(), CloudBackupError> { @@ -321,12 +325,12 @@ fn ensure_cloud_labels_size(size: usize, label_state: &str) -> Result<(), CloudB return Ok(()); } - Err(CloudBackupError::Internal(format!( - "{label_state} labels exceed {MAX_CLOUD_LABELS_SIZE} byte limit", - ))) + Err(CloudBackupError::Internal( + format!("{label_state} labels exceed {MAX_CLOUD_LABELS_SIZE} byte limit",).into(), + )) } -pub fn convert_cloud_secret(secret: &CloudWalletSecret) -> LocalWalletSecret { +pub(crate) fn convert_cloud_secret(secret: &CloudWalletSecret) -> LocalWalletSecret { match secret { CloudWalletSecret::Mnemonic(mnemonic) => LocalWalletSecret::Mnemonic(mnemonic.clone()), CloudWalletSecret::TapSignerBackup(backup) => { @@ -336,7 +340,7 @@ pub fn convert_cloud_secret(secret: &CloudWalletSecret) -> LocalWalletSecret { } } -pub fn descriptor_pair_from_cloud( +pub(crate) fn descriptor_pair_from_cloud( descriptors: &Option, ) -> Option { descriptors.as_ref().map(|descriptors| LocalDescriptorPair { diff --git a/rust/src/manager/cloud_backup_manager/wallets/restore.rs b/rust/src/manager/cloud_backup_manager/wallets/restore.rs index 31748394c..02beac5fa 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/restore.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/restore.rs @@ -3,7 +3,6 @@ use std::str::FromStr as _; use cove_cspp::backup_data::{EncryptedWalletBackup, WalletEntry}; use cove_cspp::wallet_crypto; use cove_device::cloud_storage::{CloudStorageClient, CloudStorageError}; -use cove_util::ResultExt as _; use tracing::{info, warn}; use zeroize::Zeroizing; @@ -49,7 +48,7 @@ impl WalletBackupReader { "download {record_id}: not found in cloud backup" ))), WalletBackupLookup::UnsupportedVersion(version) => Err(CloudBackupError::Internal( - format!("download {record_id}: unsupported wallet backup version {version}"), + format!("download {record_id}: unsupported wallet backup version {version}").into(), )), } } @@ -75,8 +74,10 @@ impl WalletBackupReader { ) -> Result, CloudBackupError> { match self.lookup_entry(record_id).await? { WalletBackupLookup::Found(entry) => { - let metadata = serde_json::from_value(entry.metadata.clone()) - .map_err_prefix("parse wallet metadata", CloudBackupError::Internal)?; + let metadata = + serde_json::from_value(entry.metadata.clone()).map_err(|source| { + CloudBackupError::internal_context("parse wallet metadata", source) + })?; Ok(WalletBackupLookup::Found(DownloadedWalletBackup { metadata, entry })) } WalletBackupLookup::NotFound => Ok(WalletBackupLookup::NotFound), @@ -94,12 +95,14 @@ impl WalletBackupReader { WalletBackupLookup::Found(encrypted) => { let entry = self .decrypt_entry(&encrypted) - .map_err_prefix("decrypt wallet", CloudBackupError::Crypto)?; + .map_err(|source| CloudBackupError::crypto_context("decrypt wallet", source))?; encrypted .remote_metadata .normalized_wallet(&self.namespace, record_id, Some(entry.wallet_id.as_str())) .map_err(|error| { - CloudBackupError::Internal(format!("normalize wallet payload: {error}")) + CloudBackupError::Internal( + format!("normalize wallet payload: {error}").into(), + ) })?; Ok(WalletBackupLookup::Found(entry)) @@ -127,7 +130,7 @@ impl WalletBackupReader { }; let encrypted: EncryptedWalletBackup = serde_json::from_slice(&wallet_json) - .map_err_prefix("deserialize wallet", CloudBackupError::Internal)?; + .map_err(|source| CloudBackupError::internal_context("deserialize wallet", source))?; if encrypted.version != 1 { let version = encrypted.version; @@ -223,8 +226,9 @@ impl DownloadedWalletBackup { labels_jsonl: None, }; - identity_key_for_backup(&self.metadata, &backup_model) - .map_err_prefix("cloud wallet duplicate identity", CloudBackupError::Internal) + identity_key_for_backup(&self.metadata, &backup_model).map_err(|source| { + CloudBackupError::internal_context("cloud wallet duplicate identity", source) + }) } fn fallback_duplicate_key(&self) -> WalletIdentityKey { @@ -242,12 +246,15 @@ impl DownloadedWalletBackup { match &backup_model.secret { LocalWalletSecret::Mnemonic(words) => { - let mnemonic = bip39::Mnemonic::from_str(words) - .map_err_prefix("invalid mnemonic", CloudBackupError::Internal)?; + let mnemonic = bip39::Mnemonic::from_str(words).map_err(|source| { + CloudBackupError::internal_context("invalid mnemonic", source) + })?; crate::backup::import::restore_cloud_mnemonic_wallet(&self.metadata, mnemonic) .map_err(|(error, _)| { - CloudBackupError::Internal(format!("restore mnemonic wallet: {error}")) + CloudBackupError::Internal( + format!("restore mnemonic wallet: {error}").into(), + ) })?; } _ => { @@ -256,7 +263,7 @@ impl DownloadedWalletBackup { &backup_model, ) .map_err(|(error, _)| { - CloudBackupError::Internal(format!("restore descriptor wallet: {error}")) + CloudBackupError::Internal(format!("restore descriptor wallet: {error}").into()) })?; } } diff --git a/rust/src/manager/cloud_backup_manager/wallets/upload.rs b/rust/src/manager/cloud_backup_manager/wallets/upload.rs index 155b6f981..047152cac 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/upload.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/upload.rs @@ -11,7 +11,6 @@ use cove_cspp::backup_data::wallet_record_id; use cove_cspp::wallet_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient, CloudStorageError}; use cove_device::keychain::Keychain; -use cove_util::ResultExt as _; use tracing::info; use zeroize::Zeroizing; @@ -112,7 +111,7 @@ impl DirtyWalletUploadPreparationError { impl RustCloudBackupManager { /// Upload wallets to cloud and update local cache - pub async fn do_backup_wallets( + pub(crate) async fn do_backup_wallets( &self, wallets: &[crate::wallet::metadata::WalletMetadata], ) -> Result<(), CloudBackupError> { @@ -303,20 +302,21 @@ impl RustCloudBackupManager { Ok(()) => Err(upload_error), Err(persist_error) => Err(CloudBackupError::Internal(format!( "wallet upload failed: {upload_error}; persist partial wallet upload batch failed: {persist_error}" - ))), + ) + .into())), } } - pub async fn do_upload_wallet_if_dirty( + pub(crate) async fn do_upload_wallet_if_dirty( &self, wallet_id: &crate::wallet::metadata::WalletId, ) -> Result<(), CloudBackupError> { self.ensure_cloud_backup_writes_allowed()?; let record_id = wallet_record_id(wallet_id.as_ref()); - let Some(current_state) = Database::global() - .cloud_blob_sync_states - .get(&record_id) - .map_err_prefix("read cloud blob sync state", CloudBackupError::Internal)? + let Some(current_state) = + Database::global().cloud_blob_sync_states.get(&record_id).map_err(|source| { + CloudBackupError::internal_context("read cloud blob sync state", source) + })? else { return Ok(()); }; @@ -371,7 +371,9 @@ impl RustCloudBackupManager { let wrote_uploading = Database::global() .cloud_blob_sync_states .set_if_current(¤t_state, &uploading_state) - .map_err_prefix("persist uploading cloud blob state", CloudBackupError::Internal)?; + .map_err(|source| { + CloudBackupError::internal_context("persist uploading cloud blob state", source) + })?; if !wrote_uploading { return Ok(()); } @@ -416,7 +418,7 @@ impl RustCloudBackupManager { ) -> Result<(), CloudBackupError> { let issue = CloudStorageIssue::from(&error); let retryable = Self::is_upload_failure_retryable(&error); - let persisted_issue = Self::cloud_blob_failure_issue(issue); + let persisted_issue = Self::persistable_cloud_storage_issue(issue); let cloud_error = CloudBackupError::CloudStorage(error); if is_connectivity_related_issue(issue) { self.mark_blob_dirty_state(current_state)?; @@ -453,7 +455,7 @@ impl RustCloudBackupManager { current_state, revision_hash, Self::is_upload_preparation_failure_retryable(&error), - Self::cloud_blob_failure_issue(issue), + Self::persistable_cloud_storage_issue(issue), error.to_string(), )?; Err(error) @@ -478,10 +480,12 @@ impl RustCloudBackupManager { let wrote_dirty = Database::global() .cloud_blob_sync_states .set_if_current(¤t_state, &dirty_state) - .map_err_prefix( - "persist stale uploading cloud blob state", - CloudBackupError::Internal, - )?; + .map_err(|source| { + CloudBackupError::internal_context( + "persist stale uploading cloud blob state", + source, + ) + })?; Ok(wrote_dirty.then_some(dirty_state)) } @@ -518,11 +522,11 @@ impl RustCloudBackupManager { &critical_key, remote_metadata, ) - .map_err_str(CloudBackupError::Crypto) + .map_err(CloudBackupError::crypto) .map_err(|source| DirtyWalletUploadPreparationError::new(source, revision_hash.clone()))?; let wallet_json = serde_json::to_vec(&encrypted) - .map_err_str(CloudBackupError::Internal) + .map_err(CloudBackupError::internal) .map_err(|source| DirtyWalletUploadPreparationError::new(source, revision_hash))?; Ok(PreparedDirtyWalletUpload { prepared, wallet_json }) @@ -558,10 +562,10 @@ impl RustCloudBackupManager { return Ok(()); } - let current_state = Database::global() - .cloud_blob_sync_states - .get(&record_id) - .map_err_prefix("read cloud blob sync state", CloudBackupError::Internal)?; + let current_state = + Database::global().cloud_blob_sync_states.get(&record_id).map_err(|source| { + CloudBackupError::internal_context("read cloud blob sync state", source) + })?; if let Some(current_state) = current_state { let _ = self.mark_blob_uploaded_pending_confirmation_if_current( @@ -635,9 +639,9 @@ async fn prepare_cloud_wallet_upload( critical_key, remote_metadata, ) - .map_err_str(CloudBackupError::Crypto)?; + .map_err(CloudBackupError::crypto)?; - let wallet_json = serde_json::to_vec(&encrypted).map_err_str(CloudBackupError::Internal)?; + let wallet_json = serde_json::to_vec(&encrypted).map_err(CloudBackupError::internal)?; Ok(PreparedCloudWalletUpload { prepared, wallet_json }) } diff --git a/rust/src/manager/coin_control_manager.rs b/rust/src/manager/coin_control_manager.rs index 380c13675..c0305d717 100644 --- a/rust/src/manager/coin_control_manager.rs +++ b/rust/src/manager/coin_control_manager.rs @@ -18,14 +18,12 @@ use parking_lot::Mutex; use crate::{ label_manager::{LabelManager, LabelManagerError}, - manager::deferred_sender::{self, DeferredSender}, + manager::deferred_sender, wallet::metadata::WalletMetadata, }; -use cove_tokio::task; -use flume::Receiver; use tracing::trace; -use super::deferred_sender::MessageSender; +use super::reconcile_channel::ReconcileChannel; type Message = CoinControlManagerReconcileMessage; type Action = CoinControlManagerAction; @@ -44,8 +42,7 @@ pub trait CoinControlManagerReconciler: Send + Sync + std::fmt::Debug + 'static #[derive(Clone, Debug, uniffi::Object)] pub struct RustCoinControlManager { pub state: Arc>, - pub reconciler: MessageSender, - pub reconcile_receiver: Arc>, + pub reconciler: ReconcileChannel, } #[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] pub enum CoinControlManagerReconcileMessage { @@ -196,15 +193,11 @@ impl RustCoinControlManager { #[uniffi::method] pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - task::spawn(async move { - while let Ok(field) = reconcile_receiver.recv_async().await { - trace!("reconcile_receiver: {field:?}"); - match field { - SingleOrMany::Single(message) => reconciler.reconcile(message), - SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), - } + self.reconciler.listen_async(move |field| { + trace!("reconcile_receiver: {field:?}"); + match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), } }); } @@ -243,18 +236,12 @@ impl RustCoinControlManager { impl RustCoinControlManager { pub fn new(metadata: WalletMetadata, local_outputs: Vec) -> Self { - let (sender, receiver) = flume::bounded(10); - let mut state = State::new(metadata, local_outputs); state.sort_utxos(CoinControlListSort::Date(ListSortDirection::Descending)); state.load_utxo_labels(); - Self { - state: Arc::new(Mutex::new(state)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), - } + Self { state: Arc::new(Mutex::new(state)), reconciler: ReconcileChannel::new(10) } } pub fn total_value_of_utxos(&self, selected_utxo_ids: &[Arc]) -> Amount { @@ -289,7 +276,7 @@ impl RustCoinControlManager { } } - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let sort = get_new_sort(current_sort, sort_button_pressed); self.state.lock().sort = SortState::Active(sort); @@ -300,7 +287,7 @@ impl RustCoinControlManager { } fn toggle_select_all(self: Arc) { - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let old_selected_utxos = self.state.lock().selected_utxos.clone(); @@ -351,7 +338,7 @@ impl RustCoinControlManager { return; } - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); // update the search state self.state.lock().search = search.clone(); @@ -585,7 +572,7 @@ mod tests { let (wallet_db, _tmp) = new_test_wallet_data_db(wallet_id); manager.clone().notify_search_changed(search); - while manager.reconcile_receiver.try_recv().is_ok() {} + while manager.reconciler.receiver().try_recv().is_ok() {} wallet_db .labels @@ -594,7 +581,7 @@ mod tests { manager.reload_labels().await; - let message = manager.reconcile_receiver.recv_async().await.expect("reconcile message"); + let message = manager.reconciler.receiver().recv_async().await.expect("reconcile message"); let SingleOrMany::Single(Message::UpdateUtxos(utxos)) = message else { panic!("expected utxo update"); }; @@ -718,13 +705,7 @@ impl CoinControlListSortKey { impl RustCoinControlManager { #[uniffi::constructor(default(output_count = 20, change_count = 4))] pub fn preview_new(output_count: u8, change_count: u8) -> Self { - let (sender, receiver) = flume::bounded(10); - let state = State::preview_new(output_count, change_count); - Self { - state: Arc::new(Mutex::new(state)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), - } + Self { state: Arc::new(Mutex::new(state)), reconciler: ReconcileChannel::new(10) } } } diff --git a/rust/src/manager/deferred_sender.rs b/rust/src/manager/deferred_sender.rs index 96c61d764..e07c63da7 100644 --- a/rust/src/manager/deferred_sender.rs +++ b/rust/src/manager/deferred_sender.rs @@ -82,6 +82,12 @@ impl MessageSender { error!("unable to send message to send flow manager: {err}"); } } + + /// Clone the underlying flume sender to share with actors that hold a raw + /// `Sender>` and feed the same channel + pub fn raw(&self) -> Sender> { + self.sender.clone() + } } impl Drop for DeferredSender { diff --git a/rust/src/manager/import_wallet_manager.rs b/rust/src/manager/import_wallet_manager.rs index d59b4eb5c..e97120aa4 100644 --- a/rust/src/manager/import_wallet_manager.rs +++ b/rust/src/manager/import_wallet_manager.rs @@ -1,9 +1,5 @@ -use std::sync::Arc; - use bip39::{Language, Mnemonic}; use cove_util::result_ext::ResultExt as _; -use flume::{Receiver, Sender}; -use parking_lot::RwLock; use crate::{ app::reconcile::{Update, Updater}, @@ -18,38 +14,10 @@ use crate::{ }, }; -use cove_macros::impl_default_for; use tracing::{info, warn}; -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum ImportWalletManagerReconcileMessage { - NoOp, -} - -#[uniffi::export(callback_interface)] -pub trait ImportWalletManagerReconciler: Send + Sync + std::fmt::Debug + 'static { - /// Tells the frontend to reconcile the view model changes - fn reconcile(&self, message: ImportWalletManagerReconcileMessage); -} - #[derive(Clone, Debug, uniffi::Object)] -pub struct RustImportWalletManager { - #[allow(dead_code)] - pub state: Arc>, - #[allow(dead_code)] - pub reconciler: Sender, - pub reconcile_receiver: Arc>, -} - -#[derive(Clone, Debug, uniffi::Record)] -pub struct ImportWalletManagerState {} - -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, uniffi::Enum)] -pub enum ImportWalletManagerAction { - NoOp, -} - -impl_default_for!(RustImportWalletManager); +pub struct RustImportWalletManager; #[derive(Debug, Clone, uniffi::Error, thiserror::Error)] #[uniffi::export(Display)] @@ -82,25 +50,7 @@ pub type Error = ImportWalletError; impl RustImportWalletManager { #[uniffi::constructor] pub fn new() -> Self { - let (sender, receiver) = flume::bounded(1000); - - Self { - state: Arc::new(RwLock::new(ImportWalletManagerState::new())), - reconciler: sender, - reconcile_receiver: Arc::new(receiver), - } - } - - #[uniffi::method] - pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - // call the reconcile method on the frontend - reconciler.reconcile(field); - } - }); + Self } /// Import wallet view from entered words @@ -184,19 +134,4 @@ impl RustImportWalletManager { Ok(metadata) } - - /// Action from the frontend to change the state of the view model - #[uniffi::method] - pub const fn dispatch(&self, action: ImportWalletManagerAction) { - match action { - ImportWalletManagerAction::NoOp => {} - } - } -} - -impl_default_for!(ImportWalletManagerState); -impl ImportWalletManagerState { - pub const fn new() -> Self { - Self {} - } } diff --git a/rust/src/manager/onboarding_manager.rs b/rust/src/manager/onboarding_manager.rs index 861c7191c..6343f393a 100644 --- a/rust/src/manager/onboarding_manager.rs +++ b/rust/src/manager/onboarding_manager.rs @@ -1,14 +1,7 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; +use std::{sync::Arc, time::Duration}; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_util::ResultExt as _; -use flume::Receiver; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use tracing::info; @@ -36,6 +29,7 @@ use crate::{ }; use super::deferred_sender::{DeferredSender, MessageSender, SingleOrMany}; +use super::reconcile_channel::ReconcileChannel; mod cloud_restore; mod flow_state; @@ -50,7 +44,7 @@ pub(crate) use self::cloud_restore::{ cloud_check_inconclusive_message, determine_cloud_check_outcome, inspect_cloud_restore_backup, }; #[cfg(test)] -use self::flow_state::RestoreOrigin; +use self::flow_state::{CloudCheckState, RestoreOrigin}; pub(crate) use self::flow_state::{ CloudRestoreDiscovery, CompletionTarget, CreatedWalletFlow, FlowState, InternalEvent, InternalState, OnboardingCloudBackupEnableStart, PostOnboardingDestination, TermsContext, @@ -237,17 +231,13 @@ impl From for CloudCheckIssue { #[derive(Clone, Debug, uniffi::Object)] pub struct RustOnboardingManager { state: Arc>, - cloud_check_in_flight: Arc, - pending_cloud_check_retry: Arc, - reconciler: MessageSender, - reconcile_receiver: Arc>>, + reconciler: ReconcileChannel, } #[uniffi::export] impl RustOnboardingManager { #[uniffi::constructor] pub fn new() -> Arc { - let (sender, receiver) = flume::bounded(100); let has_wallets = !Database::global().wallets.all().unwrap_or_default().is_empty(); let resolution = resolve_initial_flow( Self::load_onboarding_progress(), @@ -275,10 +265,7 @@ impl RustOnboardingManager { let manager = Arc::new(Self { state: Arc::new(RwLock::new(InternalState::new(resolution.flow))), - cloud_check_in_flight: Arc::new(AtomicBool::new(false)), - pending_cloud_check_retry: Arc::new(AtomicBool::new(false)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(100), }); manager.start_connectivity_listener(); @@ -291,17 +278,11 @@ impl RustOnboardingManager { } pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - match field { - SingleOrMany::Single(message) => reconciler.reconcile(message), - SingleOrMany::Many(messages) => { - for message in messages { - reconciler.reconcile(message); - } - } + self.reconciler.listen(move |field| match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => { + for message in messages { + reconciler.reconcile(message); } } }); @@ -322,35 +303,8 @@ impl RustOnboardingManager { pub fn dispatch(&self, action: OnboardingAction) { info!("Onboarding: dispatch action={action:?}"); - let command = self.mutate_state(|state, deferred| { - let restore_attempt_id = if matches!( - action, - OnboardingAction::StartRestore | OnboardingAction::RetryRestore - ) { - let attempt_id = state.next_restore_attempt_id; - state.next_restore_attempt_id = state.next_restore_attempt_id.wrapping_add(1); - Some(attempt_id) - } else { - None - }; - let command = state.flow.apply_user_action( - action.clone(), - state.cloud_restore_discovery.clone(), - &mut state.restore_offer_allowed, - restore_attempt_id, - ); - if matches!(action, OnboardingAction::DismissCloudRestoreAlert) - && matches!( - state.flow, - FlowState::HardwareImport | FlowState::SoftwareImport { .. } - ) - { - state.cloud_restore_alert_dismissed = true; - } - - state.sync_ui(deferred); - command - }); + let command = + self.mutate_state(|state, deferred| state.apply_user_action(action, deferred)); self.run_command(command); } } @@ -377,39 +331,21 @@ impl RustOnboardingManager { return; } - if self.cloud_check_in_flight.load(Ordering::Acquire) - && !self.mark_pending_cloud_check_retry() - { - return; - } - - self.start_offline_cloud_check_retry(); - } - - fn mark_pending_cloud_check_retry(&self) -> bool { - self.pending_cloud_check_retry.store(true, Ordering::Release); - - if self.cloud_check_in_flight.load(Ordering::Acquire) { - return false; - } - - self.pending_cloud_check_retry.swap(false, Ordering::AcqRel) - } - - fn start_offline_cloud_check_retry(self: &Arc) { - if !self.prepare_offline_cloud_check_retry() { - return; - } - - self.start_cloud_check(); + let command = self.mutate_state(|state, deferred| { + state.apply_event(InternalEvent::OfflineCloudCheckRetryRequested, deferred) + }); + self.run_command(command); } fn start_cloud_check(self: &Arc) { - if self.cloud_check_in_flight.swap(true, Ordering::AcqRel) { - return; - } + let command = self.mutate_state(|state, deferred| { + state.apply_event(InternalEvent::CloudCheckRequested, deferred) + }); + self.run_command(command); + } - let me = Arc::clone(self); + fn spawn_cloud_check(&self) { + let me = Arc::new(self.clone()); cove_tokio::task::spawn(async move { if CLOUD_BACKUP_MANAGER.is_known_offline() { me.finish_cloud_check(CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline)); @@ -428,15 +364,11 @@ impl RustOnboardingManager { }); } - fn prepare_offline_cloud_check_retry(&self) -> bool { - self.mutate_state(|state, deferred| state.prepare_offline_cloud_check_retry(deferred)) - } - fn finish_cloud_check(self: &Arc, outcome: CloudCheckOutcome) { let should_retry = self.finish_cloud_check_and_prepare_retry(outcome, CONNECTIVITY_MANAGER.connected()); if should_retry { - self.start_cloud_check(); + self.spawn_cloud_check(); } } @@ -445,44 +377,21 @@ impl RustOnboardingManager { outcome: CloudCheckOutcome, connected: bool, ) -> bool { - self.mutate_state(|state, deferred| { - state.flow.apply_event( - InternalEvent::CloudCheckFinished(outcome.clone()), - &mut state.cloud_restore_discovery, - state.restore_offer_allowed, - ); - self.cloud_check_in_flight.store(false, Ordering::Release); - - let retry_was_requested = self.pending_cloud_check_retry.swap(false, Ordering::AcqRel); - let should_retry_offline_cloud_check = retry_was_requested - && outcome == CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline) - && connected; - if should_retry_offline_cloud_check && state.prepare_offline_cloud_check_retry(deferred) - { - return true; - } - - state.sync_ui(deferred); - false - }) + let command = self.mutate_state(|state, deferred| { + state.apply_event(InternalEvent::CloudCheckFinished { outcome, connected }, deferred) + }); + command == TransitionCommand::StartCloudCheck } fn apply_event(&self, event: InternalEvent) { - let command = self.mutate_state(|state, deferred| { - state.flow.apply_event( - event, - &mut state.cloud_restore_discovery, - state.restore_offer_allowed, - ); - state.sync_ui(deferred); - TransitionCommand::None - }); + let command = self.mutate_state(|state, deferred| state.apply_event(event, deferred)); self.run_command(command); } fn run_command(&self, command: TransitionCommand) { match command { TransitionCommand::None => {} + TransitionCommand::StartCloudCheck => self.spawn_cloud_check(), TransitionCommand::CreateWallet(branch) => self.create_wallet_for_branch(branch), TransitionCommand::StartRestore { attempt_id } => { self.start_restore_attempt(attempt_id); @@ -497,7 +406,7 @@ impl RustOnboardingManager { fn start_restore_attempt(&self, attempt_id: u64) { let receiver = CLOUD_BACKUP_MANAGER.restore_from_cloud_backup_with_events(); let state = self.state.clone(); - let reconciler = self.reconciler.clone(); + let reconciler = self.reconciler.sender().clone(); cove_tokio::task::spawn(async move { while let Ok(event) = receiver.recv_async().await { @@ -520,7 +429,7 @@ impl RustOnboardingManager { }); let state = self.state.clone(); - let reconciler = self.reconciler.clone(); + let reconciler = self.reconciler.sender().clone(); cove_tokio::task::spawn(async move { tokio::time::sleep(Duration::from_secs(120)).await; @@ -539,7 +448,7 @@ impl RustOnboardingManager { } fn is_restore_attempt_current(state: &Arc>, attempt_id: u64) -> bool { - state.read().flow.is_restore_attempt_current(attempt_id) + state.read().is_restore_attempt_current(attempt_id) } fn apply_restore_event( @@ -548,13 +457,8 @@ impl RustOnboardingManager { event: InternalEvent, ) -> bool { Self::mutate_state_fields(state, reconciler, |state, deferred| { - let was_current_restore_attempt = state.flow.is_restore_event_current(&event); - state.flow.apply_event( - event, - &mut state.cloud_restore_discovery, - state.restore_offer_allowed, - ); - state.sync_ui(deferred); + let was_current_restore_attempt = state.is_restore_event_current(&event); + state.apply_event(event, deferred); was_current_restore_attempt }) } @@ -629,7 +533,7 @@ impl RustOnboardingManager { where F: FnOnce(&mut InternalState, &mut DeferredSender) -> R, { - Self::mutate_state_fields(&self.state, &self.reconciler, mutate) + Self::mutate_state_fields(&self.state, self.reconciler.sender(), mutate) } fn mutate_state_fields( @@ -656,7 +560,7 @@ impl RustOnboardingManager { } fn create_wallet(branch: OnboardingBranch) -> Result { - let pending_wallet = PendingWallet::new(NumberOfBip39Words::Twelve, None); + let pending_wallet = PendingWallet::new(NumberOfBip39Words::Twelve); let mnemonic = pending_wallet.mnemonic.clone(); let words = pending_wallet.words(); let network = pending_wallet.network; @@ -1777,7 +1681,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(None)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(None), + connected: true, + }, &mut discovery, true, ); @@ -1796,7 +1703,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(None)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(None), + connected: true, + }, &mut discovery, true, ); @@ -1813,7 +1723,10 @@ mod tests { ); state.flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(None)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(None), + connected: true, + }, &mut state.cloud_restore_discovery, state.restore_offer_allowed, ); @@ -2031,7 +1944,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::NoBackupConfirmed), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::NoBackupConfirmed, + connected: true, + }, &mut discovery, true, ); @@ -2046,9 +1962,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::Inconclusive( - CloudCheckIssue::CloudUnavailable, - )), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::Inconclusive(CloudCheckIssue::CloudUnavailable), + connected: true, + }, &mut discovery, true, ); @@ -2066,9 +1983,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::Inconclusive( - CloudCheckIssue::Offline, - )), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline), + connected: false, + }, &mut discovery, true, ); @@ -2084,9 +2002,10 @@ mod tests { let mut discovery = CloudRestoreDiscovery::Checking; flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::Inconclusive( - CloudCheckIssue::CloudUnavailable, - )), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::Inconclusive(CloudCheckIssue::CloudUnavailable), + connected: true, + }, &mut discovery, true, ); @@ -2277,7 +2196,10 @@ mod tests { assert_eq!(state.ui.cloud_restore_state, OnboardingCloudRestoreState::Checking); state.flow.apply_event( - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(None)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(None), + connected: true, + }, &mut state.cloud_restore_discovery, state.restore_offer_allowed, ); @@ -2320,19 +2242,18 @@ mod tests { FlowState::Welcome { error_message: None }, CloudRestoreDiscovery::Checking, ); - manager.cloud_check_in_flight.store(true, Ordering::Release); + manager.state.write().cloud_check = CloudCheckState::InFlight; manager.handle_connectivity_change(true); - assert!(manager.pending_cloud_check_retry.load(Ordering::Acquire)); + assert_eq!(cloud_check_state(&manager), CloudCheckState::InFlightWithRetryQueued); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::Checking); assert!(manager.finish_cloud_check_and_prepare_retry( CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline), true, )); - assert!(!manager.cloud_check_in_flight.load(Ordering::Acquire)); - assert!(!manager.pending_cloud_check_retry.load(Ordering::Acquire)); + assert_eq!(cloud_check_state(&manager), CloudCheckState::InFlight); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::Checking); assert_eq!(manager.state().cloud_restore_message, None); assert_no_reconcile_messages(&manager); @@ -2345,17 +2266,21 @@ mod tests { FlowState::Welcome { error_message: None }, CloudRestoreDiscovery::Checking, ); - manager.cloud_check_in_flight.store(true, Ordering::Release); + manager.state.write().cloud_check = CloudCheckState::InFlight; assert!(!manager.finish_cloud_check_and_prepare_retry( CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline), true, )); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::Inconclusive); + assert_eq!(cloud_check_state(&manager), CloudCheckState::Idle); - assert!(manager.mark_pending_cloud_check_retry()); - assert!(!manager.pending_cloud_check_retry.load(Ordering::Acquire)); - assert!(manager.prepare_offline_cloud_check_retry()); + let command = manager.mutate_state(|state, deferred| { + state.apply_event(InternalEvent::OfflineCloudCheckRetryRequested, deferred) + }); + + assert_eq!(command, TransitionCommand::StartCloudCheck); + assert_eq!(cloud_check_state(&manager), CloudCheckState::InFlight); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::Checking); assert_eq!(manager.state().cloud_restore_message, None); } @@ -2367,7 +2292,7 @@ mod tests { FlowState::CloudCheck { origin: RestoreOrigin::Welcome }, CloudRestoreDiscovery::Checking, ); - manager.cloud_check_in_flight.store(true, Ordering::Release); + manager.state.write().cloud_check = CloudCheckState::InFlight; manager.handle_connectivity_change(true); @@ -2375,8 +2300,7 @@ mod tests { CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline), true, )); - assert!(!manager.cloud_check_in_flight.load(Ordering::Acquire)); - assert!(!manager.pending_cloud_check_retry.load(Ordering::Acquire)); + assert_eq!(cloud_check_state(&manager), CloudCheckState::InFlight); assert_eq!(manager.state().step, OnboardingStep::CloudCheck); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::Checking); assert_eq!(manager.state().cloud_restore_message, None); @@ -2390,7 +2314,7 @@ mod tests { FlowState::Welcome { error_message: None }, CloudRestoreDiscovery::Checking, ); - manager.cloud_check_in_flight.store(true, Ordering::Release); + manager.state.write().cloud_check = CloudCheckState::InFlight; manager.handle_connectivity_change(true); @@ -2398,8 +2322,7 @@ mod tests { !manager .finish_cloud_check_and_prepare_retry(CloudCheckOutcome::NoBackupConfirmed, true,) ); - assert!(!manager.cloud_check_in_flight.load(Ordering::Acquire)); - assert!(!manager.pending_cloud_check_retry.load(Ordering::Acquire)); + assert_eq!(cloud_check_state(&manager), CloudCheckState::Idle); assert_eq!(manager.state().cloud_restore_state, OnboardingCloudRestoreState::NoBackupFound); } @@ -2660,6 +2583,7 @@ mod tests { InternalState { flow, + cloud_check: CloudCheckState::Idle, cloud_restore_discovery, restore_offer_allowed, cloud_restore_alert_dismissed, @@ -2674,19 +2598,21 @@ mod tests { ) -> Arc { crate::database::test_support::init_test_database(); - let (sender, receiver) = flume::bounded(16); - Arc::new(RustOnboardingManager { state: Arc::new(RwLock::new(preview_internal_state(flow, cloud_restore_discovery))), - cloud_check_in_flight: Arc::new(AtomicBool::new(false)), - pending_cloud_check_retry: Arc::new(AtomicBool::new(false)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(16), }) } + fn cloud_check_state(manager: &RustOnboardingManager) -> CloudCheckState { + manager.state.read().cloud_check + } + fn assert_no_reconcile_messages(manager: &RustOnboardingManager) { - assert!(matches!(manager.reconcile_receiver.try_recv(), Err(flume::TryRecvError::Empty))); + assert!(matches!( + manager.reconciler.receiver().try_recv(), + Err(flume::TryRecvError::Empty) + )); } fn apply_action(flow: &mut FlowState, action: OnboardingAction) -> TransitionCommand { @@ -2732,6 +2658,7 @@ mod tests { fn prepare_offline_cloud_check_retry(state: &mut InternalState) -> bool { let (sender, _receiver) = flume::bounded(16); let mut deferred = DeferredSender::new(MessageSender::new(sender)); - state.prepare_offline_cloud_check_retry(&mut deferred) + state.apply_event(InternalEvent::OfflineCloudCheckRetryRequested, &mut deferred) + == TransitionCommand::StartCloudCheck } } diff --git a/rust/src/manager/onboarding_manager/flow_state.rs b/rust/src/manager/onboarding_manager/flow_state.rs index 8fac6ee97..5e2cfe053 100644 --- a/rust/src/manager/onboarding_manager/flow_state.rs +++ b/rust/src/manager/onboarding_manager/flow_state.rs @@ -110,6 +110,7 @@ pub(crate) enum FlowState { #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum TransitionCommand { None, + StartCloudCheck, CreateWallet(OnboardingBranch), StartRestore { attempt_id: u64 }, BeginCloudBackupEnable { discovery: CloudRestoreDiscovery }, @@ -118,7 +119,9 @@ pub(crate) enum TransitionCommand { #[derive(Debug, Clone)] pub(crate) enum InternalEvent { - CloudCheckFinished(CloudCheckOutcome), + CloudCheckRequested, + OfflineCloudCheckRetryRequested, + CloudCheckFinished { outcome: CloudCheckOutcome, connected: bool }, RestoreProgress { attempt_id: u64, flow: CloudBackupRestoreFlow }, RestoreComplete { attempt_id: u64, report: CloudBackupRestoreReport }, RestoreNoBackupFound { attempt_id: u64 }, @@ -128,6 +131,13 @@ pub(crate) enum InternalEvent { CompletionFailed { error: String }, } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum CloudCheckState { + Idle, + InFlight, + InFlightWithRetryQueued, +} + #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum CloudRestoreDiscovery { Checking, @@ -165,6 +175,7 @@ pub(crate) enum RestoreOrigin { #[derive(Debug, Clone)] pub(crate) struct InternalState { pub(crate) flow: FlowState, + pub(crate) cloud_check: CloudCheckState, pub(crate) cloud_restore_discovery: CloudRestoreDiscovery, pub(crate) restore_offer_allowed: bool, pub(crate) cloud_restore_alert_dismissed: bool, @@ -174,6 +185,7 @@ pub(crate) struct InternalState { impl InternalState { pub(crate) fn new(flow: FlowState) -> Self { + let cloud_check = CloudCheckState::Idle; let cloud_restore_discovery = CloudRestoreDiscovery::Checking; let restore_offer_allowed = true; let cloud_restore_alert_dismissed = false; @@ -181,6 +193,7 @@ impl InternalState { let ui = flow.ui_state(&cloud_restore_discovery, false, false); Self { flow, + cloud_check, cloud_restore_discovery, restore_offer_allowed, cloud_restore_alert_dismissed, @@ -189,7 +202,69 @@ impl InternalState { } } - pub(crate) fn prepare_offline_cloud_check_retry( + pub(crate) fn apply_user_action( + &mut self, + action: OnboardingAction, + deferred: &mut DeferredSender, + ) -> TransitionCommand { + let restore_attempt_id = + if matches!(action, OnboardingAction::StartRestore | OnboardingAction::RetryRestore) { + let attempt_id = self.next_restore_attempt_id; + self.next_restore_attempt_id = self.next_restore_attempt_id.wrapping_add(1); + Some(attempt_id) + } else { + None + }; + let command = self.flow.apply_user_action( + action.clone(), + self.cloud_restore_discovery.clone(), + &mut self.restore_offer_allowed, + restore_attempt_id, + ); + if matches!(action, OnboardingAction::DismissCloudRestoreAlert) + && matches!(self.flow, FlowState::HardwareImport | FlowState::SoftwareImport { .. }) + { + self.cloud_restore_alert_dismissed = true; + } + + self.sync_ui(deferred); + command + } + + pub(crate) fn apply_event( + &mut self, + event: InternalEvent, + deferred: &mut DeferredSender, + ) -> TransitionCommand { + match event { + InternalEvent::CloudCheckRequested => self.request_cloud_check(), + InternalEvent::OfflineCloudCheckRetryRequested => { + self.request_offline_cloud_check_retry(deferred) + } + InternalEvent::CloudCheckFinished { outcome, connected } => { + self.finish_cloud_check(outcome, connected, deferred) + } + event => { + self.flow.apply_event( + event, + &mut self.cloud_restore_discovery, + self.restore_offer_allowed, + ); + self.sync_ui(deferred); + TransitionCommand::None + } + } + } + + pub(crate) fn is_restore_event_current(&self, event: &InternalEvent) -> bool { + self.flow.is_restore_event_current(event) + } + + pub(crate) fn is_restore_attempt_current(&self, attempt_id: u64) -> bool { + self.flow.is_restore_attempt_current(attempt_id) + } + + fn prepare_offline_cloud_check_retry( &mut self, deferred: &mut DeferredSender, ) -> bool { @@ -206,6 +281,61 @@ impl InternalState { true } + fn request_cloud_check(&mut self) -> TransitionCommand { + if self.cloud_check != CloudCheckState::Idle { + return TransitionCommand::None; + } + + self.cloud_check = CloudCheckState::InFlight; + TransitionCommand::StartCloudCheck + } + + fn request_offline_cloud_check_retry( + &mut self, + deferred: &mut DeferredSender, + ) -> TransitionCommand { + match self.cloud_check { + CloudCheckState::InFlight | CloudCheckState::InFlightWithRetryQueued => { + self.cloud_check = CloudCheckState::InFlightWithRetryQueued; + TransitionCommand::None + } + CloudCheckState::Idle => { + if !self.prepare_offline_cloud_check_retry(deferred) { + return TransitionCommand::None; + } + + self.cloud_check = CloudCheckState::InFlight; + TransitionCommand::StartCloudCheck + } + } + } + + fn finish_cloud_check( + &mut self, + outcome: CloudCheckOutcome, + connected: bool, + deferred: &mut DeferredSender, + ) -> TransitionCommand { + let retry_was_requested = self.cloud_check == CloudCheckState::InFlightWithRetryQueued; + self.flow.apply_event( + InternalEvent::CloudCheckFinished { outcome: outcome.clone(), connected }, + &mut self.cloud_restore_discovery, + self.restore_offer_allowed, + ); + self.cloud_check = CloudCheckState::Idle; + + let should_retry_offline_cloud_check = retry_was_requested + && outcome == CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline) + && connected; + if should_retry_offline_cloud_check && self.prepare_offline_cloud_check_retry(deferred) { + self.cloud_check = CloudCheckState::InFlight; + return TransitionCommand::StartCloudCheck; + } + + self.sync_ui(deferred); + TransitionCommand::None + } + pub(crate) fn sync_ui(&mut self, deferred: &mut DeferredSender) { let next_ui = self.flow.ui_state( &self.cloud_restore_discovery, @@ -501,12 +631,12 @@ impl FlowState { (origin.flow_state(), TransitionCommand::None) } (Self::RestoreOffline { origin }, OnboardingAction::ContinueWithoutCloudRestore) => { - (origin.flow_state_after_restore_unavailable(), TransitionCommand::None) + (origin.flow_state(), TransitionCommand::None) } ( Self::RestoreUnavailable { origin }, OnboardingAction::ContinueWithoutCloudRestore, - ) => (origin.flow_state_after_restore_unavailable(), TransitionCommand::None), + ) => (origin.flow_state(), TransitionCommand::None), (Self::RestoreFailed { origin, .. }, OnboardingAction::RetryRestore) => { let attempt_id = restore_attempt_id.expect("restore attempt id required for RetryRestore"); @@ -583,7 +713,7 @@ impl FlowState { cloud_restore_discovery: &mut CloudRestoreDiscovery, restore_offer_allowed: bool, ) { - if let InternalEvent::CloudCheckFinished(outcome) = &event { + if let InternalEvent::CloudCheckFinished { outcome, .. } = &event { *cloud_restore_discovery = CloudRestoreDiscovery::from(outcome.clone()); } @@ -592,35 +722,53 @@ impl FlowState { let next = match (current, event) { ( Self::CloudCheck { origin }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(_)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(_), + .. + }, ) => Self::RestoreOffer { origin, error_message: None }, ( Self::CloudCheck { origin }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::NoBackupConfirmed), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::NoBackupConfirmed, + .. + }, ) => Self::RestoreUnavailable { origin }, ( Self::CloudCheck { origin }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::Inconclusive(issue)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::Inconclusive(issue), + .. + }, ) => Self::restore_inconclusive_entry_for(issue, origin), ( Self::Welcome { .. }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(_)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(_), + .. + }, ) if restore_offer_allowed => { Self::RestoreOffer { origin: RestoreOrigin::Welcome, error_message: None } } ( Self::BitcoinChoice { .. }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(_)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(_), + .. + }, ) if restore_offer_allowed => { Self::RestoreOffer { origin: RestoreOrigin::BitcoinChoice, error_message: None } } ( Self::StorageChoice { .. }, - InternalEvent::CloudCheckFinished(CloudCheckOutcome::BackupFound(_)), + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::BackupFound(_), + .. + }, ) if restore_offer_allowed => { Self::RestoreOffer { origin: RestoreOrigin::StorageChoice, error_message: None } } - (state, InternalEvent::CloudCheckFinished(_)) => state, + (state, InternalEvent::CloudCheckFinished { .. }) => state, ( Self::Restoring { origin, attempt_id, .. }, InternalEvent::RestoreProgress { attempt_id: event_attempt_id, flow }, @@ -1047,8 +1195,127 @@ impl RestoreOrigin { Self::SoftwareImport => FlowState::SoftwareImport { error_message: None }, } } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manager::deferred_sender::{MessageSender, SingleOrMany}; + + #[test] + fn in_flight_cloud_check_retry_request_queues_single_retry() { + let mut state = InternalState::new(FlowState::Welcome { error_message: None }); + + let (command, messages) = apply_event(&mut state, InternalEvent::CloudCheckRequested); + + assert_eq!(command, TransitionCommand::StartCloudCheck); + assert_eq!(state.cloud_check, CloudCheckState::InFlight); + assert!(messages.is_empty()); + + let (command, messages) = apply_event(&mut state, InternalEvent::CloudCheckRequested); + + assert_eq!(command, TransitionCommand::None); + assert_eq!(state.cloud_check, CloudCheckState::InFlight); + assert!(messages.is_empty()); + + let (command, messages) = + apply_event(&mut state, InternalEvent::OfflineCloudCheckRetryRequested); + + assert_eq!(command, TransitionCommand::None); + assert_eq!(state.cloud_check, CloudCheckState::InFlightWithRetryQueued); + assert!(messages.is_empty()); + + let (command, messages) = + apply_event(&mut state, InternalEvent::OfflineCloudCheckRetryRequested); + + assert_eq!(command, TransitionCommand::None); + assert_eq!(state.cloud_check, CloudCheckState::InFlightWithRetryQueued); + assert!(messages.is_empty()); + } + + #[test] + fn queued_cloud_check_retry_is_consumed_by_offline_finish() { + let mut state = + InternalState::new(FlowState::CloudCheck { origin: RestoreOrigin::Welcome }); + + assert_eq!( + apply_event(&mut state, InternalEvent::CloudCheckRequested).0, + TransitionCommand::StartCloudCheck + ); + assert_eq!( + apply_event(&mut state, InternalEvent::OfflineCloudCheckRetryRequested).0, + TransitionCommand::None + ); + assert_eq!(state.cloud_check, CloudCheckState::InFlightWithRetryQueued); + + let (command, messages) = apply_event( + &mut state, + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::Inconclusive(CloudCheckIssue::Offline), + connected: true, + }, + ); + + assert_eq!(command, TransitionCommand::StartCloudCheck); + assert_eq!(state.cloud_check, CloudCheckState::InFlight); + assert_eq!(state.cloud_restore_discovery, CloudRestoreDiscovery::Checking); + assert!(matches!(state.flow, FlowState::CloudCheck { origin: RestoreOrigin::Welcome })); + assert_eq!(state.ui.step, OnboardingStep::CloudCheck); + assert_eq!(state.ui.cloud_restore_message, None); + assert!(messages.is_empty()); + } + + #[test] + fn idle_cloud_check_transitions_start_finish_and_retry() { + let mut state = InternalState::new(FlowState::Welcome { error_message: None }); + + let (command, messages) = apply_event(&mut state, InternalEvent::CloudCheckRequested); + + assert_eq!(command, TransitionCommand::StartCloudCheck); + assert_eq!(state.cloud_check, CloudCheckState::InFlight); + assert!(messages.is_empty()); + + let (command, _) = apply_event( + &mut state, + InternalEvent::CloudCheckFinished { + outcome: CloudCheckOutcome::NoBackupConfirmed, + connected: true, + }, + ); + + assert_eq!(command, TransitionCommand::None); + assert_eq!(state.cloud_check, CloudCheckState::Idle); + assert_eq!(state.cloud_restore_discovery, CloudRestoreDiscovery::NoBackupFound); + assert!(matches!(state.flow, FlowState::Welcome { error_message: None })); + + state.cloud_restore_discovery = + CloudRestoreDiscovery::Inconclusive(CloudCheckIssue::Offline); + let (mut deferred, _) = deferred_sender(); + state.sync_ui(&mut deferred); + + let (command, _) = apply_event(&mut state, InternalEvent::OfflineCloudCheckRetryRequested); + + assert_eq!(command, TransitionCommand::StartCloudCheck); + assert_eq!(state.cloud_check, CloudCheckState::InFlight); + assert_eq!(state.cloud_restore_discovery, CloudRestoreDiscovery::Checking); + assert!(matches!(state.flow, FlowState::Welcome { error_message: None })); + assert_eq!(state.ui.cloud_restore_state, OnboardingCloudRestoreState::Checking); + assert_eq!(state.ui.cloud_restore_message, None); + } + + fn apply_event( + state: &mut InternalState, + event: InternalEvent, + ) -> (TransitionCommand, Vec>) { + let (mut deferred, receiver) = deferred_sender(); + let command = state.apply_event(event, &mut deferred); + drop(deferred); + + (command, receiver.try_iter().collect()) + } - fn flow_state_after_restore_unavailable(self) -> FlowState { - self.flow_state() + fn deferred_sender() -> (DeferredSender, flume::Receiver>) { + let (sender, receiver) = flume::bounded(16); + (DeferredSender::new(MessageSender::new(sender)), receiver) } } diff --git a/rust/src/manager/pending_wallet_manager.rs b/rust/src/manager/pending_wallet_manager.rs index 47e5849c6..c073ea337 100644 --- a/rust/src/manager/pending_wallet_manager.rs +++ b/rust/src/manager/pending_wallet_manager.rs @@ -1,17 +1,20 @@ use std::sync::Arc; -use flume::{Receiver, Sender}; use parking_lot::RwLock; use crate::{ database::{self, Database}, keychain::KeychainError, - manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER, + manager::{ + cloud_backup_manager::CLOUD_BACKUP_MANAGER, deferred_sender::SingleOrMany, + reconcile_channel::ReconcileChannel, + }, mnemonic::{GroupedWord, MnemonicExt as _, NumberOfBip39Words, WordAccess as _}, multi_format::MultiFormatError, pending_wallet::PendingWallet, router::{HotWalletRoute, NewWalletRoute, Route}, wallet::{Wallet, fingerprint::Fingerprint, metadata::WalletMetadata}, + xpub::XpubError, }; type Error = PendingWalletManagerError; @@ -40,8 +43,7 @@ pub trait PendingWalletManagerReconciler: Send + Sync + std::fmt::Debug + 'stati #[derive(Debug, Clone, uniffi::Object)] pub struct RustPendingWalletManager { pub state: Arc>, - pub reconciler: Sender, - pub reconcile_receiver: Arc>, + pub reconciler: ReconcileChannel, } #[derive(Debug, Clone, uniffi::Record)] @@ -91,12 +93,9 @@ pub enum WalletCreationError { impl RustPendingWalletManager { #[uniffi::constructor] pub fn new(number_of_words: NumberOfBip39Words) -> Self { - let (sender, receiver) = flume::bounded(1000); - Self { state: Arc::new(RwLock::new(PendingWalletManagerState::new(number_of_words))), - reconciler: sender, - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(1000), } } @@ -158,12 +157,12 @@ impl RustPendingWalletManager { // boilerplate methods #[uniffi::method] pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - // call the reconcile method on the frontend - reconciler.reconcile(field); + self.reconciler.listen(move |field| match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => { + for message in messages { + reconciler.reconcile(message); + } } }); } @@ -175,13 +174,11 @@ impl RustPendingWalletManager { PendingWalletManagerAction::UpdateWords(words) => { { let mut state = self.state.write(); - state.wallet = PendingWallet::new(words, None).into(); + state.wallet = PendingWallet::new(words).into(); state.number_of_words = words; } - self.reconciler - .send(PendingWalletManagerReconcileMessage::Words(words)) - .expect("failed to send update"); + self.reconciler.send_sync(PendingWalletManagerReconcileMessage::Words(words)); } } } @@ -189,7 +186,7 @@ impl RustPendingWalletManager { impl PendingWalletManagerState { pub fn new(number_of_words: NumberOfBip39Words) -> Self { - Self { number_of_words, wallet: PendingWallet::new(number_of_words, None).into() } + Self { number_of_words, wallet: PendingWallet::new(number_of_words).into() } } } @@ -209,7 +206,7 @@ impl From for WalletCreationError { WalletError::BdkError(error) => Self::Bdk(error), WalletError::PersistError(error) => Self::Persist(error), WalletError::MultiFormat(error) => Self::MultiFormat(error), - WalletError::ParseXpubError(error) => Self::Import(error.to_string()), + WalletError::ParseXpubError(error) => error.into(), WalletError::WalletAlreadyExists(id) => { Self::Import(format!("wallet already exists: {id}")) } @@ -233,6 +230,12 @@ impl From for WalletCreationError { } } +impl From for WalletCreationError { + fn from(error: XpubError) -> Self { + Self::Import(error.to_string()) + } +} + fn post_save_routes( wallet_id: crate::wallet::metadata::WalletId, cloud_backup_enabled: bool, diff --git a/rust/src/manager/reconcile_channel.rs b/rust/src/manager/reconcile_channel.rs new file mode 100644 index 000000000..0e6e4c815 --- /dev/null +++ b/rust/src/manager/reconcile_channel.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use flume::{Receiver, Sender}; +use tracing::error; + +use crate::manager::deferred_sender::{DebugSend, DeferredSender, MessageSender, SingleOrMany}; + +/// Shared reconcile-channel plumbing used by every manager +/// +/// Owns a bounded flume channel whose send side is a [`MessageSender`] and whose +/// receive side is drained by a single listener that forwards each message to the +/// FFI reconciler +#[derive(Debug)] +pub struct ReconcileChannel { + sender: MessageSender, + receiver: Arc>>, +} + +impl Clone for ReconcileChannel { + fn clone(&self) -> Self { + Self { sender: self.sender.clone(), receiver: self.receiver.clone() } + } +} + +impl ReconcileChannel { + pub fn new(capacity: usize) -> Self { + let (sender, receiver) = flume::bounded(capacity); + Self { sender: MessageSender::new(sender), receiver: Arc::new(receiver) } + } + + pub fn send(&self, message: impl Into>) { + self.sender.send(message); + } + + /// Blocking send on the raw flume sender: preserves strict FIFO with no tokio + /// dependency, for managers that predate the async-fallback [`send`](Self::send) + pub fn send_sync(&self, message: impl Into>) { + if let Err(err) = self.sender.raw().send(message.into()) { + error!("unable to send reconcile message: {err}"); + } + } + + pub async fn send_async(&self, message: impl Into>) { + self.sender.send_async(message).await; + } + + pub fn sender(&self) -> &MessageSender { + &self.sender + } + + pub fn deferred_sender(&self) -> DeferredSender { + DeferredSender::new(self.sender.clone()) + } + + /// Clone the underlying flume sender to share with actors that hold a raw + /// `Sender>` and feed the same channel + pub fn raw_sender(&self) -> Sender> { + self.sender.raw() + } + + #[cfg(test)] + pub fn receiver(&self) -> Arc>> { + self.receiver.clone() + } + + /// Spawn a dedicated OS thread that forwards each received message to `handler` + pub fn listen(&self, mut handler: impl FnMut(SingleOrMany) + Send + 'static) { + let receiver = self.receiver.clone(); + std::thread::spawn(move || { + while let Ok(field) = receiver.recv() { + handler(field); + } + }); + } + + /// Spawn a tokio task that forwards each received message to `handler` + pub fn listen_async(&self, mut handler: impl FnMut(SingleOrMany) + Send + 'static) { + let receiver = self.receiver.clone(); + cove_tokio::task::spawn(async move { + while let Ok(field) = receiver.recv_async().await { + handler(field); + } + }); + } +} + +#[cfg(test)] +mod tests { + use crate::manager::deferred_sender::SingleOrMany; + + use super::ReconcileChannel; + + #[derive(Debug, Clone, PartialEq, Eq)] + enum TestMessage { + One, + Two, + Three, + } + + #[test] + fn send_sync_forwards_single_message() { + let channel = ReconcileChannel::new(1); + + channel.send_sync(TestMessage::One); + + assert_eq!(channel.receiver().recv().unwrap(), SingleOrMany::Single(TestMessage::One)); + } + + #[test] + fn deferred_sender_flushes_many_messages_on_drop() { + let channel = ReconcileChannel::new(1); + + { + let mut sender = channel.deferred_sender(); + sender.queue(TestMessage::One); + sender.queue(TestMessage::Two); + sender.queue(TestMessage::Three); + } + + assert_eq!( + channel.receiver().recv().unwrap(), + SingleOrMany::Many(vec![TestMessage::One, TestMessage::Two, TestMessage::Three]) + ); + } +} diff --git a/rust/src/manager/send_flow_manager.rs b/rust/src/manager/send_flow_manager.rs index 3453d9619..2058c00b3 100644 --- a/rust/src/manager/send_flow_manager.rs +++ b/rust/src/manager/send_flow_manager.rs @@ -42,13 +42,13 @@ use cove_types::{ }; use error::SendFlowError; use fiat_on_change::FiatOnChangeHandler; -use flume::Receiver; use parking_lot::Mutex; use state::{CoinControlMode, EnterMode, FeeSelection, SendFlowManagerState, State}; use tracing::{debug, error, trace}; use super::{ - deferred_sender::{self, MessageSender}, + deferred_sender, + reconcile_channel::ReconcileChannel, wallet_manager::{RustWalletManager, actor::WalletActor}, }; @@ -85,8 +85,7 @@ pub struct RustSendFlowManager { wallet_manager: Arc, pub state: Arc>, - reconciler: MessageSender, - reconcile_receiver: Arc>, + reconciler: ReconcileChannel, fee_check_task: DebouncedTask<()>, } @@ -165,9 +164,7 @@ impl RustSendFlowManager { balance: Arc, wallet_manager: Arc, ) -> Arc { - let (sender, receiver) = flume::bounded(50); let state = State::new(metadata, balance); - let message_sender = MessageSender::new(sender); // immediately populate cached values if available let has_base_fees = if let Some(fee_response) = FEE_CLIENT.fees() { @@ -195,8 +192,7 @@ impl RustSendFlowManager { app: App::global().clone(), state: state.into_inner(), wallet_manager, - reconciler: message_sender, - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(50), fee_check_task: DebouncedTask::new("fee_check", Duration::from_millis(200)), } .into(); @@ -215,15 +211,11 @@ impl RustSendFlowManager { impl RustSendFlowManager { #[uniffi::method] pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - cove_tokio::task::spawn(async move { - while let Ok(field) = reconcile_receiver.recv_async().await { - trace!("reconcile_receiver: {field:?}"); - match field { - SingleOrMany::Single(message) => reconciler.reconcile(message), - SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), - } + self.reconciler.listen_async(move |field| { + trace!("reconcile_receiver: {field:?}"); + match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), } }); } @@ -256,7 +248,7 @@ impl RustSendFlowManager { } pub(crate) fn validate_amount_internal(self: &Arc, display_alert: bool) -> bool { - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let Some(amount) = self.state.lock().amount_sats else { let msg = Message::SetAlert(SendFlowError::InvalidNumber.into()); if display_alert { @@ -720,7 +712,6 @@ mod tests { crate::database::test_support::init_test_database(); crate::test_support::ensure_tokio_runtime(); - let (sender, receiver) = flume::bounded(50); let balance = Arc::new(Balance::default()); let state = super::State::new(WalletMetadata::preview_new(), balance); let wallet_manager = Arc::new(RustWalletManager::preview_new_wallet()); @@ -729,8 +720,7 @@ mod tests { app: super::App::global().clone(), wallet_manager, state: state.into_inner(), - reconciler: super::MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: super::ReconcileChannel::new(50), fee_check_task: super::DebouncedTask::new( "fee_check", super::Duration::from_millis(200), @@ -802,7 +792,7 @@ mod tests { } fn next_reconcile_message(manager: &super::RustSendFlowManager) -> super::Message { - let message = manager.reconcile_receiver.try_recv().expect("message is reconciled"); + let message = manager.reconciler.receiver().try_recv().expect("message is reconciled"); let SingleOrMany::Single(message) = message else { panic!("expected a single reconcile message"); }; @@ -812,8 +802,9 @@ mod tests { fn drain_reconcile_messages(manager: &super::RustSendFlowManager) -> Vec { let mut messages = Vec::new(); + let receiver = manager.reconciler.receiver(); - while let Ok(message) = manager.reconcile_receiver.try_recv() { + while let Ok(message) = receiver.try_recv() { match message { SingleOrMany::Single(message) => messages.push(message), SingleOrMany::Many(batch) => messages.extend(batch), @@ -1244,7 +1235,7 @@ mod tests { manager.finalize_and_go_to_next_screen(); - assert!(manager.reconcile_receiver.try_recv().is_err()); + assert!(manager.reconciler.receiver().try_recv().is_err()); } #[test] @@ -1256,7 +1247,8 @@ mod tests { manager.finalize_and_go_to_next_screen(); let message = manager - .reconcile_receiver + .reconciler + .receiver() .recv_timeout(Duration::from_secs(2)) .expect("dust alert is reconciled"); diff --git a/rust/src/manager/send_flow_manager/address_input.rs b/rust/src/manager/send_flow_manager/address_input.rs index 855567bc7..3fd7559a7 100644 --- a/rust/src/manager/send_flow_manager/address_input.rs +++ b/rust/src/manager/send_flow_manager/address_input.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use cove_types::address::AddressWithNetwork; use tracing::debug; -use super::{DeferredSender, Message, RustSendFlowManager, SendFlowError, SetAmountFocusField}; +use super::{Message, RustSendFlowManager, SendFlowError, SetAmountFocusField}; impl RustSendFlowManager { /// Called when the user types or pastes into the address field. @@ -11,7 +11,7 @@ impl RustSendFlowManager { pub(crate) fn handle_entering_address_changed(self: &Arc, address: String) { debug!("handle_entering_address_changed: {address}"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); self.state.lock().entering_address = address.clone(); @@ -90,7 +90,7 @@ impl RustSendFlowManager { state.amount_fiat = None; } - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); sender.queue(Message::UpdateAmountFiat(0.0)); sender.queue(Message::UpdateAmountSats(0)); self.schedule_fee_rate_update(); @@ -110,7 +110,7 @@ impl RustSendFlowManager { } pub(crate) fn clear_address(self: &Arc) { - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); { let mut state = self.state.lock(); if state.address.is_some() { @@ -131,7 +131,7 @@ impl RustSendFlowManager { new_value: String, ) { debug!("handle_scan_code_changed {new_value}"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let network = self.state.lock().metadata.network; let address_with_network = { diff --git a/rust/src/manager/send_flow_manager/amount_input.rs b/rust/src/manager/send_flow_manager/amount_input.rs index aa0ac5599..c7b598dbd 100644 --- a/rust/src/manager/send_flow_manager/amount_input.rs +++ b/rust/src/manager/send_flow_manager/amount_input.rs @@ -24,7 +24,7 @@ impl RustSendFlowManager { } // update the state - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); self.state.lock().entering_btc_amount = new.clone(); let state: State = self.state.clone().into(); @@ -98,7 +98,7 @@ impl RustSendFlowManager { return None; } - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); // update the state self.state.lock().entering_fiat_amount = new_value.clone(); @@ -159,7 +159,7 @@ impl RustSendFlowManager { fee_rate: Arc, ) { debug!("selected_fee_rate_changed: {fee_rate:?}"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); if let Some(options) = self.fee_rate_options() { let selection = FeeSelection::new(options, fee_rate.clone()); { @@ -191,7 +191,7 @@ impl RustSendFlowManager { pub(crate) fn handle_amount_changed(self: &Arc, amount: Amount) { debug!("handle_amount_changed: {amount:?}"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let (unit, fiat_or_btc, btc_price_in_fiat) = { let state = self.state.lock(); @@ -254,7 +254,7 @@ impl RustSendFlowManager { ) { debug!("handle_focus_field_changed: {old:?} --> {new:?}"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); // most likely the first load, so ignore for now let front end handle it if old.is_none() && new.is_some() && self.state.lock().focus_field.is_none() { @@ -323,7 +323,7 @@ impl RustSendFlowManager { self.reconciler.send(Message::SetAlert(SendFlowError::SendBelowDustLimit.into())); } Err(error) => { - let error = SendFlowError::UnableToGetMaxSend(error.to_string()); + let error = Error::unable_to_get_max_send(error); self.reconciler.send(Message::SetAlert(error.into())); } } @@ -331,7 +331,7 @@ impl RustSendFlowManager { pub(crate) async fn select_max_send(self: &Arc) -> Result<()> { debug!("select_max_send"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); // access the mutex once let (address, fee_rate_options, selected_fee_rate, selected_fee_rate_base) = { @@ -400,7 +400,7 @@ impl RustSendFlowManager { old: BitcoinUnit, new: BitcoinUnit, ) { - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); self.state.lock().metadata.selected_unit = new; sender.queue(Message::RefreshPresenters); @@ -447,7 +447,7 @@ impl RustSendFlowManager { _old_value: FiatOrBtc, new_value: FiatOrBtc, ) { - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); self.state.lock().metadata.fiat_or_btc = new_value; sender.queue(Message::RefreshPresenters); diff --git a/rust/src/manager/send_flow_manager/error.rs b/rust/src/manager/send_flow_manager/error.rs index 6b2b9bea8..c2a4b22a0 100644 --- a/rust/src/manager/send_flow_manager/error.rs +++ b/rust/src/manager/send_flow_manager/error.rs @@ -70,6 +70,22 @@ impl SendFlowError { _ => Self::InvalidAddress(address), } } + + pub(crate) fn unable_to_build_txn(error: impl std::fmt::Display) -> Self { + Self::UnableToBuildTxn(error.to_string()) + } + + pub(crate) fn unable_to_get_fee_details(error: impl std::fmt::Display) -> Self { + Self::UnableToGetFeeDetails(error.to_string()) + } + + pub(crate) fn unable_to_get_max_send(error: impl std::fmt::Display) -> Self { + Self::UnableToGetMaxSend(error.to_string()) + } + + pub(crate) fn unable_to_save_unsigned_transaction(error: impl std::fmt::Display) -> Self { + Self::UnableToSaveUnsignedTransaction(error.to_string()) + } } #[cfg(test)] diff --git a/rust/src/manager/send_flow_manager/fee_selection.rs b/rust/src/manager/send_flow_manager/fee_selection.rs index 1268e6037..ca5ace1dc 100644 --- a/rust/src/manager/send_flow_manager/fee_selection.rs +++ b/rust/src/manager/send_flow_manager/fee_selection.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use act_zero::call; use cove_common::consts::LOW_SEND_WARNING_SATS; -use cove_util::result_ext::ResultExt as _; use tracing::debug; use crate::{fee_client::FEE_CLIENT, transaction::FeeRate, wallet::Address}; @@ -13,7 +12,7 @@ use cove_types::{ }; use super::{ - DeferredSender, Error, Message, Result, RustSendFlowManager, SendFlowError, state::EnterMode, + Error, Message, Result, RustSendFlowManager, SendFlowError, state::EnterMode, state::FeeSelection, }; @@ -47,7 +46,7 @@ impl RustSendFlowManager { let fee_rate = Arc::unwrap_or_clone(fee_rate); let psbt = self.build_psbt(None, None, fee_rate).await?; - let total_fee = psbt.fee().map_err_str(Error::UnableToGetFeeDetails)?; + let total_fee = psbt.fee().map_err(Error::unable_to_get_fee_details)?; let fee_rate_option = FeeRateOptionWithTotalFee { fee_speed, fee_rate, total_fee: Some(total_fee) }; @@ -92,7 +91,7 @@ impl RustSendFlowManager { pub(crate) async fn get_or_update_fee_rate_options(self: &Arc) { debug!("get_or_update_fee_rate_options"); - let mut sender = DeferredSender::new(self.reconciler.clone()); + let mut sender = self.reconciler.deferred_sender(); let (address, amount_sats) = { let state = self.state.lock(); @@ -228,15 +227,16 @@ impl RustSendFlowManager { return None; } Err(error) => { - let error = SendFlowError::UnableToGetMaxSend(error.to_string()); + let error = Error::unable_to_get_max_send(error); self.reconciler.send_async(Message::SetAlert(error.into())).await; return None; } }; - let total_fee = match psbt.fee().map_err_str(Error::UnableToGetFeeDetails) { + let total_fee = match psbt.fee().map_err(Error::unable_to_get_fee_details) { Ok(total_fee) => total_fee, Err(error) => { + let error = Error::unable_to_get_max_send(error); self.reconciler.send_async(Message::SetAlert(error.into())).await; return None; } diff --git a/rust/src/manager/send_flow_manager/finalize.rs b/rust/src/manager/send_flow_manager/finalize.rs index bbc3ff45b..8fc482516 100644 --- a/rust/src/manager/send_flow_manager/finalize.rs +++ b/rust/src/manager/send_flow_manager/finalize.rs @@ -173,7 +173,8 @@ impl RustSendFlowManager { let details = match confirm_details { Ok(details) => details, Err(error) => { - return me.send_alert_async(SendFlowError::from(error)).await; + let error = SendFlowError::from(error); + return me.send_alert_async(error).await; } }; @@ -183,7 +184,7 @@ impl RustSendFlowManager { if matches!(wallet_type, WalletType::Cold | WalletType::XpubOnly) && let Err(e) = manager.save_unsigned_transaction(details.clone()) { - let error = SendFlowError::UnableToSaveUnsignedTransaction(e.to_string()); + let error = SendFlowError::unable_to_save_unsigned_transaction(e); me.send_alert_async(error).await; } @@ -197,7 +198,7 @@ impl RustSendFlowManager { } WalletType::WatchOnly => { return me - .send_alert_async(SendFlowError::UnableToBuildTxn("watch only".to_string())) + .send_alert_async(SendFlowError::unable_to_build_txn("watch only")) .await; } }; @@ -289,8 +290,8 @@ mod tests { }; use super::super::{ - App, DebouncedTask, FeeSelection, MessageSender, RustSendFlowManager, SendFlowAlertState, - SendFlowWarningKind, + App, DebouncedTask, FeeSelection, ReconcileChannel, RustSendFlowManager, + SendFlowAlertState, SendFlowWarningKind, }; use super::*; @@ -298,7 +299,6 @@ mod tests { crate::database::test_support::init_test_database(); crate::test_support::ensure_tokio_runtime(); - let (sender, receiver) = flume::bounded(50); let balance = Arc::new(Balance::default()); let state = super::super::State::new(WalletMetadata::preview_new(), balance); let wallet_manager = Arc::new(RustWalletManager::preview_new_wallet()); @@ -307,8 +307,7 @@ mod tests { app: App::global().clone(), wallet_manager, state: state.into_inner(), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: ReconcileChannel::new(50), fee_check_task: DebouncedTask::new("fee_check", Duration::from_millis(200)), }) } @@ -427,7 +426,7 @@ mod tests { Some(total_fee), ); - let message = manager.reconcile_receiver.try_recv().expect("warning is reconciled"); + let message = manager.reconciler.receiver().try_recv().expect("warning is reconciled"); let SingleOrMany::Single(message) = message else { panic!("expected a single reconcile message"); }; diff --git a/rust/src/manager/send_flow_manager/psbt_builder.rs b/rust/src/manager/send_flow_manager/psbt_builder.rs index 4d3e88e24..c3a5768ea 100644 --- a/rust/src/manager/send_flow_manager/psbt_builder.rs +++ b/rust/src/manager/send_flow_manager/psbt_builder.rs @@ -1,13 +1,10 @@ use act_zero::call; use cove_types::{amount::Amount, psbt::Psbt}; -use cove_util::result_ext::ResultExt as _; use tracing::debug; use crate::{transaction::FeeRate, wallet::Address}; -use super::{ - AmountOrMax, CoinControlMode, EnterMode, Error, Result, RustSendFlowManager, SendFlowError, -}; +use super::{AmountOrMax, CoinControlMode, EnterMode, Result, RustSendFlowManager, SendFlowError}; impl RustSendFlowManager { pub(crate) async fn build_psbt( @@ -41,7 +38,7 @@ impl RustSendFlowManager { let amount_sats = amount .map(|amount| amount.to_sat()) .or_else(|| state.amount_sats) - .ok_or_else(|| Error::UnableToBuildTxn("no amount".to_string()))?; + .ok_or_else(|| SendFlowError::unable_to_build_txn("no amount"))?; let amount = if state.max_selected.is_some() { AmountOrMax::Max @@ -51,7 +48,7 @@ impl RustSendFlowManager { let address = address .or_else(|| state.address.clone().map(|address| address.as_ref().clone())) - .ok_or_else(|| Error::UnableToBuildTxn("no address".to_string()))?; + .ok_or_else(|| SendFlowError::unable_to_build_txn("no address"))?; (amount, address) }; @@ -90,7 +87,7 @@ impl RustSendFlowManager { let address = address .or_else(|| state.address.clone().map(|address| address.as_ref().clone())) - .ok_or_else(|| Error::UnableToBuildTxn("no address".to_string()))?; + .ok_or_else(|| SendFlowError::unable_to_build_txn("no address"))?; (address, bitcoin::Amount::from(amount)) }; @@ -100,7 +97,7 @@ impl RustSendFlowManager { let psbt = call!(actor.build_manual_ephemeral_tx(outpoints, amount, address, fee_rate)).await; - let psbt = psbt.map_err_str(SendFlowError::UnableToBuildTxn)??; + let psbt = psbt.map_err(SendFlowError::unable_to_build_txn)??; Ok(psbt.into()) } } diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 1c8e5c08a..521958351 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -19,8 +19,8 @@ use std::{ use act_zero::{Addr, call, send}; use actor::WalletActor; pub use balance_presentation::BalancePresentation; -use bdk_wallet::error::CreateTxError; -use flume::Receiver; +use bdk_wallet::{AddUtxoError, error::CreateTxError}; +use futures::channel::oneshot; pub use ledger_state::WalletLedgerState; use parking_lot::RwLock; use receive_address::{ReceiveAddressPresentation, ReceiveAddressState}; @@ -60,7 +60,8 @@ use cove_types::{confirm::AddressAndAmount, fees::FeeRateOptions}; use super::{ coin_control_manager::RustCoinControlManager, - deferred_sender::{self, DeferredSender, MessageSender}, + deferred_sender::{self, DeferredSender}, + reconcile_channel::ReconcileChannel, send_flow_manager::RustSendFlowManager, }; @@ -279,8 +280,7 @@ pub struct RustWalletManager { // faster to access, but adds complexity to the code because we have to make sure its updated // in all the places pub metadata: Arc>, - pub reconciler: MessageSender, - pub reconcile_receiver: Arc>, + pub reconciler: ReconcileChannel, scan_status: Arc>, wallet_snapshot: Arc>, unsigned_transactions: WalletBootstrapUnsignedTransactions, @@ -396,6 +396,122 @@ pub enum WalletManagerError { ReceiveAddressError(String), } +#[derive(Debug, thiserror::Error)] +pub(crate) enum WalletManagerBuildTxError { + #[error(transparent)] + Create(#[from] CreateTxError), + + #[error(transparent)] + Psbt(#[from] bitcoin::psbt::Error), +} + +impl From for WalletManagerError { + fn from(error: WalletManagerBuildTxError) -> Self { + match error { + WalletManagerBuildTxError::Create(CreateTxError::OutputBelowDustLimit(_)) => { + Self::OutputBelowDustLimit + } + WalletManagerBuildTxError::Create(CreateTxError::CoinSelection(error)) => { + Self::InsufficientFunds(error.to_string()) + } + error => Self::BuildTxError(error.to_string()), + } + } +} + +impl From for WalletManagerError { + fn from(error: CreateTxError) -> Self { + Self::from(WalletManagerBuildTxError::from(error)) + } +} + +impl From for WalletManagerError { + fn from(error: AddUtxoError) -> Self { + Self::AddUtxosError(error.to_string()) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WalletManagerFeesError { + #[error(transparent)] + Fetch(#[from] reqwest::Error), + + #[error(transparent)] + Psbt(#[from] bitcoin::psbt::Error), +} + +impl From for WalletManagerError { + fn from(error: WalletManagerFeesError) -> Self { + Self::FeesError(error.to_string()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{context}: {source}")] +pub(crate) struct WalletManagerPendingUnsignedTransactionsLoadError { + context: String, + + #[source] + source: crate::database::Error, +} + +impl WalletManagerPendingUnsignedTransactionsLoadError { + pub(crate) fn new(context: impl Into, source: crate::database::Error) -> Self { + Self { context: context.into(), source } + } +} + +impl From for WalletManagerError { + fn from(error: WalletManagerPendingUnsignedTransactionsLoadError) -> Self { + Self::PendingUnsignedTransactionsLoadError(error.to_string()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{source}")] +pub(crate) struct WalletManagerDatabaseCorruptionError { + id: WalletId, + + #[source] + source: crate::database::wallet_data::WalletDataError, +} + +impl WalletManagerDatabaseCorruptionError { + pub(crate) fn new(id: WalletId, source: crate::database::wallet_data::WalletDataError) -> Self { + Self { id, source } + } +} + +impl From for WalletManagerError { + fn from(error: WalletManagerDatabaseCorruptionError) -> Self { + Self::DatabaseCorruption { id: error.id, error: error.source.to_string() } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{source}")] +pub(crate) struct WalletManagerUnableToSwitchError { + wallet_address_type: WalletAddressType, + + #[source] + source: oneshot::Canceled, +} + +impl WalletManagerUnableToSwitchError { + pub(crate) const fn new( + wallet_address_type: WalletAddressType, + source: oneshot::Canceled, + ) -> Self { + Self { wallet_address_type, source } + } +} + +impl From for WalletManagerError { + fn from(error: WalletManagerUnableToSwitchError) -> Self { + Self::UnableToSwitch(error.wallet_address_type, error.source.to_string()) + } +} + fn initial_state_from_snapshot( metadata: WalletMetadata, scan_status: WalletScanStatus, @@ -439,10 +555,9 @@ fn unsigned_transactions_for_wallet( ) -> Result>, Error> { let db = Database::global(); let context = format!("wallet id={wallet_id:?}"); - let txns = db - .unsigned_transactions() - .get_by_wallet_id(wallet_id) - .map_err_prefix(&context, Error::PendingUnsignedTransactionsLoadError)?; + let txns = db.unsigned_transactions().get_by_wallet_id(wallet_id).map_err(|source| { + WalletManagerPendingUnsignedTransactionsLoadError::new(context.clone(), source) + })?; let txns = txns.into_iter().map(|txn| Arc::new(txn.into())).collect::>>(); @@ -450,16 +565,6 @@ fn unsigned_transactions_for_wallet( Ok(txns) } -impl From for WalletManagerError { - fn from(error: CreateTxError) -> Self { - match error { - CreateTxError::OutputBelowDustLimit(_) => Self::OutputBelowDustLimit, - CreateTxError::CoinSelection(error) => Self::InsufficientFunds(error.to_string()), - error => Self::BuildTxError(error.to_string()), - } - } -} - #[uniffi::export(async_runtime = "tokio")] impl RustWalletManager { /// Returns the bootstrap wallet snapshot used before reconcile messages arrive @@ -545,7 +650,7 @@ impl RustWalletManager { #[uniffi::method] pub async fn get_fee_options(&self) -> Result { let fee_client = &FEE_CLIENT; - let fees = fee_client.fetch_and_get_fees().await.map_err_str(Error::FeesError)?; + let fees = fee_client.fetch_and_get_fees().await.map_err(WalletManagerFeesError::from)?; Ok(fees.into()) } @@ -647,15 +752,20 @@ impl RustWalletManager { #[uniffi::method] pub async fn current_block_height(&self) -> Result { - let height = - call!(self.actor.get_height(false)).await.map_err(|_| Error::GetHeightError)?; + let height = call!(self.actor.get_height(false)) + .await + .map_err(|_| Error::GetHeightError)? + .map_err(|_| Error::GetHeightError)?; Ok(height as u32) } #[uniffi::method] pub async fn force_update_height(&self) -> Result { - let height = call!(self.actor.get_height(true)).await.map_err(|_| Error::GetHeightError)?; + let height = call!(self.actor.get_height(true)) + .await + .map_err(|_| Error::GetHeightError)? + .map_err(|_| Error::GetHeightError)?; Ok(height as u32) } @@ -845,22 +955,16 @@ impl RustWalletManager { pub async fn fee_rate_options(&self) -> Result { let fee_client = &FEE_CLIENT; - let fees = fee_client.fetch_and_get_fees().await.map_err_str(Error::FeesError)?; + let fees = fee_client.fetch_and_get_fees().await.map_err(WalletManagerFeesError::from)?; Ok(fees.into()) } #[uniffi::method] pub fn listen_for_updates(&self, reconciler: Box) { - let reconcile_receiver = self.reconcile_receiver.clone(); - - std::thread::spawn(move || { - while let Ok(field) = reconcile_receiver.recv() { - match field { - SingleOrMany::Single(message) => reconciler.reconcile(message), - SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), - } - } + self.reconciler.listen(move |field| match field { + SingleOrMany::Single(message) => reconciler.reconcile(message), + SingleOrMany::Many(messages) => reconciler.reconcile_many(messages), }); } @@ -906,6 +1010,9 @@ impl RustWalletManager { actor.switch_descriptor_to_new_address_type(descriptors, wallet_address_type) ) .await + .map_err(|source| { + WalletManagerUnableToSwitchError::new(wallet_address_type, source) + })? .map_err(|e| Error::UnableToSwitch(wallet_address_type, e.to_string()))?; self.refresh_metadata_from_database()?; @@ -918,6 +1025,9 @@ impl RustWalletManager { let actor = self.actor.clone(); call!(actor.switch_mnemonic_to_new_address_type(wallet_address_type)) .await + .map_err(|source| { + WalletManagerUnableToSwitchError::new(wallet_address_type, source) + })? .map_err(|e| Error::UnableToSwitch(wallet_address_type, e.to_string()))?; self.refresh_metadata_from_database()?; @@ -1140,24 +1250,27 @@ impl RustWalletManager { #[uniffi::constructor] pub fn preview_new_wallet_with_metadata(metadata: WalletMetadata) -> Self { let metadata = preview_ledger_ready_metadata(metadata); - let (sender, receiver) = flume::bounded(100); + let channel = ReconcileChannel::new(100); let wallet = Wallet::preview_new_wallet_with_metadata(metadata.clone()); let label_manager = LabelManager::new(wallet.metadata.id.clone()).into(); let wallet_snapshot = Arc::new(RwLock::new(WalletSnapshot::from_wallet(&wallet))); let unsigned_transactions = WalletBootstrapUnsignedTransactions::in_memory(Vec::new()); let scan_status = Arc::new(RwLock::new(WalletScanStatus::Idle)); - let wallet_actor = - WalletActor::new(wallet, sender.clone(), scan_status.clone(), wallet_snapshot.clone()) - .expect("failed to open wallet database for preview wallet"); + let wallet_actor = WalletActor::new( + wallet, + channel.raw_sender(), + scan_status.clone(), + wallet_snapshot.clone(), + ) + .expect("failed to open wallet database for preview wallet"); let actor = task::spawn_actor(wallet_actor); Self { id: metadata.id.clone(), actor, metadata: Arc::new(RwLock::new(metadata)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: channel, scan_status, wallet_snapshot, unsigned_transactions, diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index c08aaed48..ab5a6695f 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -72,6 +72,7 @@ pub struct WalletActor { // cached values, source of truth is the redb database saved with wallet metadata last_scan_finished: Option, last_height_fetched: Option<(Duration, usize)>, + height_refresh_in_flight: Option, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -164,6 +165,7 @@ impl WalletActor { node_client: None, last_scan_finished: None, last_height_fetched: None, + height_refresh_in_flight: None, state: ActorState::Initial, receive_address: ReceiveAddressSession::default(), scan_status, @@ -271,7 +273,8 @@ impl WalletActor { } if check_node { - self.ensure_node_connection().await?; + self.start_wallet_scan_after_node_connection(force_scan, progress_start); + return Produces::ok(()); } // perform that scanning in a background task @@ -284,31 +287,114 @@ impl WalletActor { Produces::ok(()) } + fn start_wallet_scan_after_node_connection( + &mut self, + force_scan: bool, + progress_start: ScanProgressStart, + ) { + let connection = self.deferred_node_connection(); + + self.addr.send_fut_with(|addr| async move { + if matches!(connection.await, Ok(Ok(()))) { + send!(addr.start_wallet_scan_in_task(force_scan, progress_start, false)); + } + }); + } + pub async fn switch_mnemonic_to_new_address_type( &mut self, address_type: WalletAddressType, - ) -> ActorResult<()> { + ) -> ActorResult> { debug!("actor switch mnemonic wallet"); - self.ensure_node_connection().await?; + let connection = self.deferred_node_connection(); + let (reply, receiver) = futures::channel::oneshot::channel(); + + self.addr.send_fut_with(|addr| async move { + let result = match connection.await { + Ok(Ok(())) => call!(addr.apply_mnemonic_address_type_switch(address_type)) + .await + .unwrap_or(Err(Error::ActorNotFound)), + Ok(Err(error)) => Err(error), + Err(_) => Err(Error::ActorNotFound), + }; + + let _ = reply.send(Produces::Value(result)); + }); + + Ok(Produces::Deferred(receiver)) + } + + async fn apply_mnemonic_address_type_switch( + &mut self, + address_type: WalletAddressType, + ) -> ActorResult> { + let result = self.apply_mnemonic_address_type_switch_inner(address_type).await; + + Produces::ok(result) + } + + async fn apply_mnemonic_address_type_switch_inner( + &mut self, + address_type: WalletAddressType, + ) -> Result<(), Error> { self.wallet.switch_mnemonic_to_new_address_type(address_type)?; - self.restart_scan_after_address_type_switch().await?; + self.restart_scan_after_address_type_switch() + .await + .map_err(|error| Error::UnableToSwitch(address_type, error.to_string()))?; - Produces::ok(()) + Ok(()) } pub async fn switch_descriptor_to_new_address_type( &mut self, descriptors: pubport::descriptor::Descriptors, address_type: WalletAddressType, - ) -> ActorResult<()> { + ) -> ActorResult> { debug!("actor switch pubkey descriptor wallet"); - self.ensure_node_connection().await?; + let connection = self.deferred_node_connection(); + let (reply, receiver) = futures::channel::oneshot::channel(); + + self.addr.send_fut_with(|addr| async move { + let result = match connection.await { + Ok(Ok(())) => { + call!(addr.apply_descriptor_address_type_switch(descriptors, address_type)) + .await + .unwrap_or(Err(Error::ActorNotFound)) + } + Ok(Err(error)) => Err(error), + Err(_) => Err(Error::ActorNotFound), + }; + + let _ = reply.send(Produces::Value(result)); + }); + + Ok(Produces::Deferred(receiver)) + } + + async fn apply_descriptor_address_type_switch( + &mut self, + descriptors: pubport::descriptor::Descriptors, + address_type: WalletAddressType, + ) -> ActorResult> { + let result = + self.apply_descriptor_address_type_switch_inner(descriptors, address_type).await; + + Produces::ok(result) + } + + async fn apply_descriptor_address_type_switch_inner( + &mut self, + descriptors: pubport::descriptor::Descriptors, + address_type: WalletAddressType, + ) -> Result<(), Error> { self.wallet.switch_descriptor_to_new_address_type(descriptors, address_type)?; - self.restart_scan_after_address_type_switch().await?; + self.restart_scan_after_address_type_switch() + .await + .map_err(|error| Error::UnableToSwitch(address_type, error.to_string()))?; - Produces::ok(()) + Ok(()) } #[into_actor_result] @@ -454,6 +540,20 @@ impl WalletActor { pub async fn perform_rescan_full_scan(&mut self, gap_limit: u32) -> ActorResult<()> { debug!("perform_rescan_full_scan with gap_limit={gap_limit}"); + let connection = self.deferred_node_connection(); + self.addr.send_fut_with(|addr| async move { + if matches!(connection.await, Ok(Ok(()))) { + send!(addr.start_rescan_full_scan_after_node_connection(gap_limit)); + } + }); + + Produces::ok(()) + } + + async fn start_rescan_full_scan_after_node_connection( + &mut self, + gap_limit: u32, + ) -> ActorResult<()> { let scan_actor = self.scan_actor(); send!(scan_actor.start_rescan(gap_limit, self.scan_generation)); @@ -470,7 +570,7 @@ impl WalletActor { return Produces::ok(None); } - let node_client = self.node_client().await?.clone(); + let node_client = self.node_client()?.clone(); let full_scan_request = match request_order { ScanRequestOrder::Standard => self.wallet.bdk.start_full_scan().build(), @@ -1054,8 +1154,9 @@ mod tests { }; use bip39::Mnemonic; use bitcoin::{ - Address as BdkAddress, Amount, BlockHash, Network, OutPoint, ScriptBuf, TxOut, Txid, - hashes::Hash as _, + Address as BdkAddress, Amount, BlockHash, Network, OutPoint, ScriptBuf, + Transaction as BdkTransaction, TxOut, Txid, absolute::LockTime, hashes::Hash as _, + transaction::Version, }; use cove_bdk_progressive_scan::ScanUpdate; use cove_device::keychain::{Keychain, KeychainAccess, KeychainError}; @@ -1064,7 +1165,10 @@ mod tests { use std::{ collections::{BTreeMap, HashMap, HashSet}, str::FromStr as _, - sync::{Arc, Once}, + sync::{ + Arc, Once, + atomic::{AtomicUsize, Ordering}, + }, time::{Duration, UNIX_EPOCH}, }; use tokio::{ @@ -1075,6 +1179,7 @@ mod tests { use crate::wallet::metadata::WalletMetadata; + use super::transactions::BroadcastTransactionError; use super::{ ActorState, EMPTY_WALLET_SCAN_PROGRESS_DELAY, FullScanType, InitialScanRoute, RETURNING_WALLET_SCAN_PROGRESS_DELAY, ScanProgressStart, SingleOrMany, @@ -1122,6 +1227,17 @@ mod tests { _tmp: tempfile::TempDir, } + struct TestEsploraNode { + requests: Arc, + height_requests: Arc, + server: JoinHandle<()>, + } + + struct BroadcastEsploraNode { + broadcast_requests: Arc, + server: JoinHandle<()>, + } + async fn actor_value(result: act_zero::ActorResult) -> T { result .expect("actor method should not fail") @@ -1133,6 +1249,21 @@ mod tests { async fn in_memory_wallet_metadata(&mut self) -> act_zero::ActorResult { act_zero::Produces::ok(self.wallet.metadata.clone()) } + + async fn set_last_height_fetched_for_test( + &mut self, + age: Duration, + block_height: usize, + ) -> act_zero::ActorResult<()> { + let now = UNIX_EPOCH.elapsed().unwrap_or_default(); + self.last_height_fetched = Some((now.saturating_sub(age), block_height)); + + act_zero::Produces::ok(()) + } + + async fn cached_height_for_test(&mut self) -> act_zero::ActorResult> { + act_zero::Produces::ok(self.last_height_fetched().map(|(_, block_height)| block_height)) + } } fn local_output_with_outpoint( @@ -1183,6 +1314,15 @@ mod tests { .expect("address is regtest") } + fn test_broadcast_transaction() -> BdkTransaction { + BdkTransaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + } + } + fn test_scan_status() -> Arc> { Arc::new(RwLock::new(WalletScanStatus::Idle)) } @@ -1354,6 +1494,160 @@ mod tests { }) } + async fn set_height_esplora_node(block_height: usize, delay: Duration) -> TestEsploraNode { + set_height_sequence_esplora_node(vec![block_height], delay).await + } + + async fn set_height_sequence_esplora_node( + block_heights: Vec, + delay: Duration, + ) -> TestEsploraNode { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test esplora server binds"); + let address = listener.local_addr().expect("test esplora server has address"); + let node = Node::new_esplora( + "height test esplora node".to_string(), + format!("http://{address}"), + CoveNetwork::Bitcoin, + ); + + crate::database::Database::global() + .global_config + .set_selected_node(&node) + .expect("test node config is saved"); + + let requests = Arc::new(AtomicUsize::new(0)); + let request_counter = requests.clone(); + let height_requests = Arc::new(AtomicUsize::new(0)); + let height_request_counter = height_requests.clone(); + let block_heights = Arc::new(block_heights); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let request_counter = request_counter.clone(); + let height_request_counter = height_request_counter.clone(); + let block_heights = block_heights.clone(); + tokio::spawn(async move { + let mut request = [0; 1024]; + let bytes_read = stream.read(&mut request).await.unwrap_or_default(); + request_counter.fetch_add(1, Ordering::SeqCst); + + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + + let request = String::from_utf8_lossy(&request[..bytes_read]); + let body = if request.starts_with("GET /blocks/tip/height ") { + let index = height_request_counter.fetch_add(1, Ordering::SeqCst); + block_heights + .get(index) + .or_else(|| block_heights.last()) + .copied() + .unwrap_or_default() + .to_string() + } else if request.starts_with("GET /block-height/") { + "0000000000000000000000000000000000000000000000000000000000000001" + .to_string() + } else { + "1".to_string() + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + TestEsploraNode { requests, height_requests, server } + } + + async fn wait_for_persisted_height(metadata: &WalletMetadata, block_height: u64) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let persisted = persisted_wallet_metadata(metadata) + .internal + .last_height_fetched + .map(|height| height.block_height); + let global_cache = crate::database::Database::global() + .global_cache + .get_block_height(metadata.network) + .expect("global block height cache loads") + .map(|height| height.block_height); + + if persisted == Some(block_height) && global_cache == Some(block_height) { + return; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("height refresh persists"); + } + + async fn set_broadcast_esplora_node(broadcast_status: u16) -> BroadcastEsploraNode { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test esplora server binds"); + let address = listener.local_addr().expect("test esplora server has address"); + let node = Node::new_esplora( + "broadcast test esplora node".to_string(), + format!("http://{address}"), + CoveNetwork::Bitcoin, + ); + + crate::database::Database::global() + .global_config + .set_selected_node(&node) + .expect("test node config is saved"); + + let broadcast_requests = Arc::new(AtomicUsize::new(0)); + let broadcast_counter = broadcast_requests.clone(); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let broadcast_counter = broadcast_counter.clone(); + tokio::spawn(async move { + let mut request = [0; 8192]; + let bytes_read = stream.read(&mut request).await.unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..bytes_read]); + + let body = if request.starts_with("POST /tx ") { + broadcast_counter.fetch_add(1, Ordering::SeqCst); + "broadcast" + } else { + "1" + }; + let status = + if request.starts_with("POST /tx ") { broadcast_status } else { 200 }; + let reason = if status == 200 { "OK" } else { "Internal Server Error" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + BroadcastEsploraNode { broadcast_requests, server } + } + + async fn wait_for_broadcast_count(server: &BroadcastEsploraNode, count: usize) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if server.broadcast_requests.load(Ordering::SeqCst) == count { + return; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("broadcast count is reached"); + } + fn restore_default_bitcoin_node() { let node = Node::default(CoveNetwork::Bitcoin); @@ -1830,6 +2124,232 @@ mod tests { assert!(matches!(error, super::Error::InsufficientFunds(_))); } + #[tokio::test(flavor = "current_thread")] + async fn broadcast_transaction_posts_to_node_exactly_once() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_broadcast_esplora_node(200).await; + + let metadata = WalletMetadata::preview_new(); + let mut wallet = persisted_preview_wallet(metadata.clone()); + mark_wallet_ledger_ready(&mut wallet); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + let result = call!(addr.broadcast_transaction(test_broadcast_transaction())) + .await + .expect("broadcast actor responds"); + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + result.expect("broadcast succeeds"); + assert_eq!(server.broadcast_requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn broadcast_transaction_propagates_node_error() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_broadcast_esplora_node(500).await; + + let metadata = WalletMetadata::preview_new(); + let mut wallet = persisted_preview_wallet(metadata.clone()); + mark_wallet_ledger_ready(&mut wallet); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + let error = call!(addr.broadcast_transaction(test_broadcast_transaction())) + .await + .expect("broadcast actor responds") + .expect_err("broadcast node error is returned"); + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(server.broadcast_requests.load(Ordering::SeqCst), 1); + assert!( + matches!(error, super::Error::SignAndBroadcastError(message) if message.contains("try again")) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn payjoin_proposal_falls_back_only_when_network_broadcast_failed() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_broadcast_esplora_node(200).await; + + let metadata = WalletMetadata::preview_new(); + let mut wallet = persisted_preview_wallet(metadata.clone()); + mark_wallet_ledger_ready(&mut wallet); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + call!(addr.handle_payjoin_proposal_broadcast_result( + Err(BroadcastTransactionError::PostBroadcastFailed( + super::Error::SignAndBroadcastError("bookkeeping failed".to_string()) + )), + test_broadcast_transaction() + )) + .await + .expect("payjoin bookkeeping result is handled"); + + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(server.broadcast_requests.load(Ordering::SeqCst), 0); + + call!(addr.handle_payjoin_proposal_broadcast_result( + Err(BroadcastTransactionError::BroadcastFailed(super::Error::SignAndBroadcastError( + "proposal failed".to_string() + ))), + test_broadcast_transaction() + )) + .await + .expect("payjoin network result is handled"); + wait_for_broadcast_count(&server, 1).await; + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(server.broadcast_requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn slow_height_refresh_does_not_block_unrelated_actor_messages() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_height_esplora_node(321, Duration::from_millis(500)).await; + + let metadata = WalletMetadata::preview_new(); + let wallet = persisted_preview_wallet(metadata.clone()); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + let height = call!(addr.get_height(true)); + tokio::time::sleep(Duration::from_millis(50)).await; + + let balance = tokio::time::timeout(Duration::from_millis(100), call!(addr.balance())) + .await + .expect("balance is not blocked by height refresh") + .expect("balance actor message completes"); + let _balance = balance; + + let height = height.await.expect("height actor message completes").expect("height loads"); + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(height, 321); + assert!(server.requests.load(Ordering::SeqCst) > 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn height_staleness_windows_return_cached_or_refresh_as_before() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + set_unreachable_bitcoin_esplora_node(); + + let metadata = WalletMetadata::preview_new(); + let wallet = persisted_preview_wallet(metadata.clone()); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + call!(addr.set_last_height_fetched_for_test(Duration::from_secs(10), 42)) + .await + .expect("test height is set"); + + let fresh_cached_height = call!(addr.get_height(false)) + .await + .expect("height actor responds") + .expect("fresh cache is returned"); + + let server = set_height_esplora_node(88, Duration::ZERO).await; + call!(addr.set_last_height_fetched_for_test(Duration::from_secs(30), 42)) + .await + .expect("test height is set"); + + let stale_cached_height = + call!(addr.get_height(false)).await.expect("height actor responds").expect("height"); + wait_for_persisted_height(&metadata, 88).await; + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(fresh_cached_height, 42); + assert_eq!(stale_cached_height, 42); + assert!(server.requests.load(Ordering::SeqCst) > 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn height_refresh_completion_updates_state_and_persists() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_height_esplora_node(144, Duration::ZERO).await; + + let metadata = WalletMetadata::preview_new(); + let wallet = persisted_preview_wallet(metadata.clone()); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + let height = call!(addr.get_height(true)) + .await + .expect("height actor responds") + .expect("height refresh succeeds"); + let cached_height = call!(addr.cached_height_for_test()) + .await + .expect("cached height actor responds") + .expect("cached height is available"); + wait_for_persisted_height(&metadata, 144).await; + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(height, 144); + assert_eq!(cached_height, 144); + } + + #[tokio::test(flavor = "current_thread")] + async fn concurrent_forced_height_refreshes_dedup_and_keep_height_monotonic() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + + crate::database::test_support::init_test_database(); + let server = set_height_sequence_esplora_node(vec![150], Duration::from_millis(200)).await; + + let metadata = WalletMetadata::preview_new(); + let wallet = persisted_preview_wallet(metadata.clone()); + let (addr, _receiver) = spawn_test_wallet_actor(wallet); + + tokio::time::sleep(Duration::from_millis(300)).await; + server.height_requests.store(0, Ordering::SeqCst); + call!(addr.set_last_height_fetched_for_test(Duration::from_secs(200), 200)) + .await + .expect("test height is set"); + + let first = call!(addr.get_height(true)); + let second = call!(addr.get_height(true)); + let (first, second) = tokio::join!(first, second); + + let first = first.expect("first height actor responds").expect("first height loads"); + let second = second.expect("second height actor responds").expect("second height loads"); + let cached_height = call!(addr.cached_height_for_test()) + .await + .expect("cached height actor responds") + .expect("cached height is available"); + + restore_default_bitcoin_node(); + server.server.abort(); + let _ = crate::wallet::delete_wallet_specific_data(&metadata.id); + + assert_eq!(first, 200); + assert_eq!(second, 200); + assert_eq!(cached_height, 200); + assert_eq!(server.height_requests.load(Ordering::SeqCst), 1); + } + #[tokio::test(flavor = "current_thread")] async fn mnemonic_address_type_switch_restarts_wallet_scan() { let _guard = address_type_switch_test_lock().lock().await; @@ -1846,6 +2366,7 @@ mod tests { call!(addr.switch_mnemonic_to_new_address_type(WalletAddressType::Legacy)) .await + .expect("address type switch actor responds") .expect("address type switches"); wait_for_wallet_scan_started(&receiver).await; @@ -1871,6 +2392,7 @@ mod tests { let _error = call!(addr.switch_mnemonic_to_new_address_type(WalletAddressType::Legacy)) .await + .expect("address type switch actor responds") .expect_err("address-type switch fails when scan startup fails"); let messages = receiver.try_iter().collect::>(); let actor_metadata = @@ -1907,6 +2429,7 @@ mod tests { call!(addr.switch_descriptor_to_new_address_type(descriptors, WalletAddressType::Legacy)) .await + .expect("address type switch actor responds") .expect("address type switches"); wait_for_wallet_scan_started(&receiver).await; @@ -1934,6 +2457,7 @@ mod tests { addr.switch_descriptor_to_new_address_type(descriptors, WalletAddressType::Legacy) ) .await + .expect("address type switch actor responds") .expect_err("address-type switch fails when scan startup fails"); let messages = receiver.try_iter().collect::>(); let actor_metadata = diff --git a/rust/src/manager/wallet_manager/actor/node.rs b/rust/src/manager/wallet_manager/actor/node.rs index 7ea90d004..83869e04e 100644 --- a/rust/src/manager/wallet_manager/actor/node.rs +++ b/rust/src/manager/wallet_manager/actor/node.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{fmt, time::Duration}; use act_zero::*; use act_zero_ext::into_actor_result; @@ -15,19 +15,56 @@ use crate::{ wallet::metadata::BlockSizeLast, }; -impl WalletActor { - pub(crate) async fn ensure_node_connection(&mut self) -> Result<(), Error> { - let node = Database::global().global_config.selected_node(); - check_node_connection_inner(&node).await.map_err(Error::NodeConnectionFailed)?; +const HEIGHT_CACHE_FRESH_SECS: u64 = 25; +const HEIGHT_CACHE_BACKGROUND_REFRESH_SECS: u64 = 120; + +type HeightRefreshResult = Result; +type BlockIdRefreshResult = Result; +pub(crate) type HeightReply = futures::channel::oneshot::Sender>>; +type BlockIdReply = futures::channel::oneshot::Sender>>; +type NodeConnectionReply = futures::channel::oneshot::Sender>>; + +struct NodeHeightRefresh { + node_client: NodeClient, + block_height: usize, +} + +struct NodeBlockIdRefresh { + node_client: NodeClient, + block_id: BlockId, +} + +pub(crate) struct HeightRefreshInFlight { + replies: Vec, +} + +impl HeightRefreshInFlight { + fn new(reply: Option) -> Self { + let replies = reply.into_iter().collect(); + + Self { replies } + } - let node_client = NodeClient::new(&node) - .await - .map_err_prefix("failed to create node client", Error::NodeConnectionFailed)?; - self.node_client = Some(node_client); + fn attach(&mut self, reply: Option) { + if let Some(reply) = reply { + self.replies.push(reply); + } + } + + fn finish(self, result: Result) { + for reply in self.replies { + let _ = reply.send(Produces::Value(result.clone())); + } + } +} - Ok(()) +impl fmt::Debug for HeightRefreshInFlight { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HeightRefreshInFlight").field("reply_count", &self.replies.len()).finish() } +} +impl WalletActor { #[into_actor_result] pub async fn check_node_connection(&mut self) { let node = Database::global().global_config.selected_node(); @@ -41,47 +78,170 @@ impl WalletActor { }); } - pub async fn get_height(&mut self, force: bool) -> ActorResult { + pub async fn get_height(&mut self, force: bool) -> ActorResult> { if let Some((last_height_fetched, block_height)) = self.last_height_fetched() { let elapsed = super::elapsed_secs_since(last_height_fetched); - if !force && elapsed < 120 { - // if less than 25 seconds return the height, without updating - if elapsed < 25 { - return Produces::ok(block_height); + if !force && elapsed < HEIGHT_CACHE_BACKGROUND_REFRESH_SECS { + if elapsed < HEIGHT_CACHE_FRESH_SECS { + return Produces::ok(Ok(block_height)); } - // if more than a minute return height immediately, but update the height in the background - send!(self.addr.update_height()); - return Produces::ok(block_height); + self.start_height_refresh(None); + return Produces::ok(Ok(block_height)); } } - // update the height and return the new height - let block_height = self.update_height().await?.await?; - Produces::ok(block_height) + Ok(self.deferred_height_refresh()) } - pub(crate) async fn update_height(&mut self) -> ActorResult { + pub(crate) async fn update_height(&mut self) -> ActorResult> { debug!("actor update_height"); - self.check_node_connection().await?; - let node_client = self.node_client().await?; - let block_height = node_client.get_height().await.map_err(|_| Error::GetHeightError)?; - self.save_last_height_fetched(block_height); - - Produces::ok(block_height) + Ok(self.deferred_height_refresh()) } - pub(crate) async fn update_block_id(&mut self) -> Result { + pub(crate) async fn update_block_id(&mut self) -> ActorResult> { debug!("actor update_block_id"); - if self.check_node_connection().await.is_err() { - return Err(Error::GetHeightError.into()); + Ok(self.deferred_block_id_refresh()) + } + + fn start_height_refresh(&mut self, reply: Option) { + if let Some(in_flight) = &mut self.height_refresh_in_flight { + in_flight.attach(reply); + return; + } + + self.height_refresh_in_flight = Some(HeightRefreshInFlight::new(reply)); + + let node = Database::global().global_config.selected_node(); + let node_client = self.node_client.clone(); + + self.addr.send_fut_with(|addr| async move { + let result = fetch_node_height(node, node_client).await; + let _ = call!(addr.handle_height_refresh_result(result)).await; + }); + } + + fn start_block_id_refresh(&mut self, reply: Option) { + let node = Database::global().global_config.selected_node(); + let node_client = self.node_client.clone(); + + self.addr.send_fut_with(|addr| async move { + let result = fetch_node_block_id(node, node_client).await; + let applied = call!(addr.handle_block_id_refresh_result(result)).await; + + if let Some(reply) = reply { + let _ = reply.send(Produces::Value(applied.unwrap_or(Err(Error::GetHeightError)))); + } + }); + } + + fn start_node_connection(&mut self, reply: Option) { + let node = Database::global().global_config.selected_node(); + + self.addr.send_fut_with(|addr| async move { + let result = checked_node_client(&node).await; + let applied = call!(addr.handle_node_connection_result(result)).await; + + if let Some(reply) = reply { + let _ = reply.send(Produces::Value(applied.unwrap_or(Err(Error::GetHeightError)))); + } + }); + } + + pub(crate) fn deferred_node_connection(&mut self) -> Produces> { + let (reply, receiver) = futures::channel::oneshot::channel(); + self.start_node_connection(Some(reply)); + + Produces::Deferred(receiver) + } + + fn deferred_height_refresh(&mut self) -> Produces> { + let (reply, receiver) = futures::channel::oneshot::channel(); + self.start_height_refresh(Some(reply)); + + Produces::Deferred(receiver) + } + + fn deferred_block_id_refresh(&mut self) -> Produces> { + let (reply, receiver) = futures::channel::oneshot::channel(); + self.start_block_id_refresh(Some(reply)); + + Produces::Deferred(receiver) + } + + async fn handle_node_connection_result( + &mut self, + result: Result, + ) -> ActorResult> { + match result { + Ok(node_client) => { + self.node_client = Some(node_client); + Produces::ok(Ok(())) + } + + Err(error) => { + self.report_node_refresh_error(&error); + Produces::ok(Err(error)) + } + } + } + + async fn handle_height_refresh_result( + &mut self, + result: HeightRefreshResult, + ) -> ActorResult> { + let result = match result { + Ok(refresh) => { + let block_height = refresh.block_height; + self.node_client = Some(refresh.node_client); + let applied_height = self.apply_last_height_fetched(block_height); + + Ok(applied_height) + } + + Err(error) => { + self.report_node_refresh_error(&error); + Err(error) + } }; - let node_client = self.node_client().await?; - let block_id = node_client.get_block_id().await.map_err(|_| Error::GetHeightError)?; - self.save_last_height_fetched(block_id.height as usize); + if let Some(in_flight) = self.height_refresh_in_flight.take() { + in_flight.finish(result.clone()); + } + + Produces::ok(result) + } + + async fn handle_block_id_refresh_result( + &mut self, + result: BlockIdRefreshResult, + ) -> ActorResult> { + match result { + Ok(refresh) => { + let block_id = refresh.block_id; + self.node_client = Some(refresh.node_client); + self.apply_last_height_fetched(block_id.height as usize); + + Produces::ok(Ok(block_id)) + } + + Err(error) => { + self.report_node_refresh_error(&error); + Produces::ok(Err(error)) + } + } + } + + fn report_node_refresh_error(&self, error: &Error) { + match error { + Error::NodeConnectionFailed(error) => { + self.send(WalletManagerReconcileMessage::NodeConnectionFailed(error.clone())); + } - Ok(block_id) + error => { + self.send(WalletManagerReconcileMessage::WalletError(error.clone())); + } + } } pub(crate) fn last_height_fetched(&mut self) -> Option<(Duration, usize)> { @@ -128,19 +288,60 @@ impl WalletActor { Some(()) } - pub(crate) async fn node_client(&mut self) -> Result<&NodeClient, Error> { - let node_client = self.node_client.as_ref(); - if node_client.is_none() { - let node = Database::global().global_config.selected_node(); - let node_client = NodeClient::new(&node) - .await - .map_err_prefix("failed to create node client", Error::NodeConnectionFailed)?; + fn apply_last_height_fetched(&mut self, block_height: usize) -> usize { + let Some((_, current_height)) = self.last_height_fetched() else { + self.save_last_height_fetched(block_height); + return block_height; + }; - self.node_client = Some(node_client); + if block_height < current_height { + return current_height; } - Ok(self.node_client.as_ref().expect("just checked")) + self.save_last_height_fetched(block_height); + block_height + } + + pub(crate) fn node_client(&mut self) -> Result<&NodeClient, Error> { + self.node_client.as_ref().ok_or_else(|| { + Error::NodeConnectionFailed("node client is not connected yet".to_string()) + }) + } +} + +async fn checked_node_client(node: &Node) -> Result { + check_node_connection_inner(node).await.map_err(Error::NodeConnectionFailed)?; + + NodeClient::new(node) + .await + .map_err_prefix("failed to create node client", Error::NodeConnectionFailed) +} + +async fn fetch_node_height(node: Node, node_client: Option) -> HeightRefreshResult { + let node_client = node_client_or_new(&node, node_client).await?; + let block_height = node_client.get_height().await.map_err(|_| Error::GetHeightError)?; + + Ok(NodeHeightRefresh { node_client, block_height }) +} + +async fn fetch_node_block_id(node: Node, node_client: Option) -> BlockIdRefreshResult { + let node_client = node_client_or_new(&node, node_client).await?; + let block_id = node_client.get_block_id().await.map_err(|_| Error::GetHeightError)?; + + Ok(NodeBlockIdRefresh { node_client, block_id }) +} + +async fn node_client_or_new( + node: &Node, + node_client: Option, +) -> Result { + if let Some(node_client) = node_client { + return Ok(node_client); } + + NodeClient::new(node) + .await + .map_err_prefix("failed to create node client", Error::NodeConnectionFailed) } async fn check_node_connection_inner(node: &Node) -> Result<(), String> { @@ -150,9 +351,8 @@ async fn check_node_connection_inner(node: &Node) -> Result<(), String> { // is not designed for concurrent access, so a fresh connection ensures no // shared state or concurrent access // - // TODO: We could optimize this to reuse the cached client when using esplora, - // since esplora uses HTTP and doesn't have the concurrent access limitations - // that electrum's persistent TCP connection has. + // todo: consider reusing the cached client when using esplora, since esplora + // uses HTTP and does not have electrum's persistent TCP concurrency limits let node_client = NodeClient::new(node) .await .map_err(|_| "unable to create a connection to the node".to_string())?; diff --git a/rust/src/manager/wallet_manager/actor/receive_address.rs b/rust/src/manager/wallet_manager/actor/receive_address.rs index 9829cdc51..83065911d 100644 --- a/rust/src/manager/wallet_manager/actor/receive_address.rs +++ b/rust/src/manager/wallet_manager/actor/receive_address.rs @@ -33,6 +33,16 @@ use crate::{ const RECEIVE_ADDRESS_FRESHNESS_TIMEOUT: Duration = Duration::from_millis(400); +enum OpenReceiveAddressDecision { + Complete(Result), + CheckCachedActivity { + cache: ReceiveAddressCache, + request_id: u64, + now: u64, + derivation_index: u32, + }, +} + impl WalletActor { pub async fn address_at(&mut self, index: u32) -> ActorResult { let address = self.wallet.bdk.peek_address(KeychainKind::External, index); @@ -42,51 +52,157 @@ impl WalletActor { pub async fn open_receive_address_intent(&mut self) -> ActorResult<()> { self.set_receive_address_loading(true); - match self.do_open_receive_address().await { - Ok(_) => self.set_receive_address_loading(false), - Err(error) => { - self.set_receive_address_loading(false); - self.send(WalletManagerReconcileMessage::ReceiveAddressError(error.to_string())); + match self.open_receive_address_decision() { + OpenReceiveAddressDecision::Complete(result) => { + self.finish_open_receive_address(result); + Produces::ok(()) + } + OpenReceiveAddressDecision::CheckCachedActivity { + cache, + request_id, + now, + derivation_index, + } => { + self.deferred_open_cached_receive_address(cache, request_id, now, derivation_index) } } - - Produces::ok(()) } - async fn do_open_receive_address(&mut self) -> Result { + fn open_receive_address_decision(&mut self) -> OpenReceiveAddressDecision { let now = current_epoch_secs(); - let Some(cache) = self.receive_address_cache()? else { + let cache = match self.receive_address_cache() { + Ok(cache) => cache, + Err(error) => return OpenReceiveAddressDecision::Complete(Err(error)), + }; + + let Some(cache) = cache else { let request_id = self.receive_address.next_request_id(); - return self.open_fresh_receive_address(request_id, now); + return OpenReceiveAddressDecision::Complete( + self.open_fresh_receive_address(request_id, now), + ); }; if !self.receive_address_cache_is_available(&cache, now) { let request_id = self.receive_address.next_request_id(); - return self.open_fresh_receive_address(request_id, now); + return OpenReceiveAddressDecision::Complete( + self.open_fresh_receive_address(request_id, now), + ); } let derivation_index = cache.derivation_index; let request_id = self.receive_address.next_request_id(); - if !matches!( - self.cached_receive_address_has_activity(derivation_index).await, - Ok(Some(true)) - ) { - return self.open_cached_receive_address(cache, request_id, now).await; - } + OpenReceiveAddressDecision::CheckCachedActivity { cache, request_id, now, derivation_index } + } - if let Err(error) = self.sync_receive_address_now(derivation_index).await { - warn!("Failed to sync used receive address index={derivation_index}: {error}"); - self.wallet.mark_receive_address_used(derivation_index)?; - self.notify_wallet_balance_and_transactions().await; + fn deferred_open_cached_receive_address( + &mut self, + cache: ReceiveAddressCache, + request_id: u64, + now: u64, + derivation_index: u32, + ) -> ActorResult<()> { + let (node, graph, sync_request) = self.receive_address_sync_inputs(derivation_index); + let address = + self.wallet.bdk.peek_address(KeychainKind::External, derivation_index).address; + let (reply, receiver) = futures::channel::oneshot::channel(); - self.start_targeted_receive_address_sync(request_id, derivation_index); + self.addr.send_fut_with(|addr| async move { + let sync_result = match NodeClient::new(&node).await { + Ok(node_client) => { + match node_client + .check_address_for_txn(address) + .with_timeout(RECEIVE_ADDRESS_FRESHNESS_TIMEOUT) + .await + { + Ok(Ok(true)) => Some( + node_client + .sync(&graph, sync_request) + .await + .map_err_str(Error::ReceiveAddressError), + ), + Ok(Ok(false)) | Ok(Err(_)) | Err(_) => None, + } + } + Err(_) => None, + }; + + let _ = call!(addr.finish_open_receive_address_after_activity_check( + cache, + request_id, + now, + derivation_index, + sync_result + )) + .await; + let _ = reply.send(Produces::Value(())); + }); + + Ok(Produces::Deferred(receiver)) + } + + async fn finish_open_receive_address_after_activity_check( + &mut self, + cache: ReceiveAddressCache, + request_id: u64, + now: u64, + derivation_index: u32, + sync_result: Option>, + ) -> ActorResult<()> { + if !self.receive_address.is_current(request_id) { + return Produces::ok(()); } + let result = if let Some(sync_result) = sync_result { + self.open_fresh_after_receive_address_sync( + request_id, + now, + derivation_index, + sync_result, + ) + .await + } else { + self.open_cached_receive_address(cache, request_id, now) + }; + + self.finish_open_receive_address(result); + + Produces::ok(()) + } + + async fn open_fresh_after_receive_address_sync( + &mut self, + request_id: u64, + now: u64, + derivation_index: u32, + sync_result: Result, + ) -> Result { + let sync_result = match sync_result { + Ok(sync_result) => sync_result, + Err(error) => { + warn!("Failed to sync used receive address index={derivation_index}: {error}"); + self.wallet.mark_receive_address_used(derivation_index)?; + self.notify_wallet_balance_and_transactions().await; + + self.start_targeted_receive_address_sync(request_id, derivation_index); + return self.open_fresh_receive_address(request_id, now); + } + }; + + self.apply_receive_address_now_sync_result(derivation_index, sync_result).await?; + self.open_fresh_receive_address(request_id, now) } + fn finish_open_receive_address(&self, result: Result) { + self.set_receive_address_loading(false); + + if let Err(error) = result { + self.send(WalletManagerReconcileMessage::ReceiveAddressError(error.to_string())); + } + } + fn receive_address_cache_is_available( &self, cache: &ReceiveAddressCache, @@ -123,7 +239,7 @@ impl WalletActor { Ok(state) } - async fn open_cached_receive_address( + fn open_cached_receive_address( &mut self, cache: ReceiveAddressCache, request_id: u64, @@ -283,25 +399,6 @@ impl WalletActor { self.receive_address_refresh_timer = None; } - async fn cached_receive_address_has_activity( - &mut self, - derivation_index: u32, - ) -> Result, Error> { - let node_client = self.node_client().await?.clone(); - let address = - self.wallet.bdk.peek_address(KeychainKind::External, derivation_index).address; - - match node_client - .check_address_for_txn(address) - .with_timeout(RECEIVE_ADDRESS_FRESHNESS_TIMEOUT) - .await - { - Ok(Ok(has_activity)) => Ok(Some(has_activity)), - Ok(Err(error)) => Err(Error::NodeConnectionFailed(error.to_string())), - Err(_) => Ok(None), - } - } - fn start_delayed_receive_address_activity_check( &mut self, request_id: u64, @@ -427,14 +524,11 @@ impl WalletActor { }); } - async fn sync_receive_address_now(&mut self, derivation_index: u32) -> Result<(), Error> { - let (node, graph, sync_request) = self.receive_address_sync_inputs(derivation_index); - let node_client = NodeClient::new(&node) - .await - .map_err_prefix("failed to create node client", Error::NodeConnectionFailed)?; - - let sync_result = - node_client.sync(&graph, sync_request).await.map_err_str(Error::ReceiveAddressError)?; + async fn apply_receive_address_now_sync_result( + &mut self, + derivation_index: u32, + sync_result: SyncResponse, + ) -> Result<(), Error> { self.wallet.bdk.apply_update(sync_result).map_err_str(Error::ReceiveAddressError)?; if self.wallet.receive_address_is_unused(derivation_index) { diff --git a/rust/src/manager/wallet_manager/actor/transaction_confirmation.rs b/rust/src/manager/wallet_manager/actor/transaction_confirmation.rs index f25ecb77d..32279dd6c 100644 --- a/rust/src/manager/wallet_manager/actor/transaction_confirmation.rs +++ b/rust/src/manager/wallet_manager/actor/transaction_confirmation.rs @@ -77,8 +77,14 @@ impl WalletActor { async fn handle_watched_transaction_confirmation(&mut self, tx_id: Txid) -> ActorResult<()> { info!("handling watched transaction confirmation: {tx_id}"); - self.update_block_id().await?; - self.perform_scan_for_single_tx_id(tx_id).await + let block_id_refresh = self.update_block_id().await?; + self.addr.send_fut_with(|addr| async move { + if matches!(block_id_refresh.await, Ok(Ok(_))) { + send!(addr.perform_scan_for_single_tx_id(tx_id)); + } + }); + + Produces::ok(()) } pub(crate) async fn remove_watcher_for_txn(&mut self, tx_id: Txid) { @@ -90,14 +96,28 @@ impl WalletActor { pub async fn perform_scan_for_single_tx_id(&mut self, tx_id: Txid) -> ActorResult<()> { let start = UNIX_EPOCH.elapsed().unwrap().as_secs(); - let _ = self.update_height().await?.await; + let height_refresh = self.update_height().await?; + self.addr.send_fut_with(|addr| async move { + if matches!(height_refresh.await, Ok(Ok(_))) { + send!(addr.start_single_tx_sync_after_height(tx_id, start)); + } + }); + + Produces::ok(()) + } + + async fn start_single_tx_sync_after_height( + &mut self, + tx_id: Txid, + start: u64, + ) -> ActorResult<()> { let chain_tip = self.wallet.bdk.local_chain().tip(); let sync_request_builder = SyncRequest::builder().txids(vec![tx_id]).chain_tip(chain_tip); let sync_request = sync_request_builder.build(); - let node_client = self.node_client().await?.clone(); + let node_client = self.node_client()?.clone(); let graph = self.wallet.bdk.tx_graph().clone(); let now = UNIX_EPOCH.elapsed().unwrap().as_secs(); diff --git a/rust/src/manager/wallet_manager/actor/transactions.rs b/rust/src/manager/wallet_manager/actor/transactions.rs index c6f30cc3b..e77de0f39 100644 --- a/rust/src/manager/wallet_manager/actor/transactions.rs +++ b/rust/src/manager/wallet_manager/actor/transactions.rs @@ -25,15 +25,31 @@ use tracing::{debug, error, warn}; use crate::{ manager::wallet_manager::{ - Error, SendFlowErrorAlert, WalletManagerError, WalletManagerReconcileMessage, + Error, SendFlowErrorAlert, WalletManagerBuildTxError, WalletManagerError, + WalletManagerFeesError, WalletManagerReconcileMessage, actor::{WalletActor, current_wallet_unspent_outpoints_for_txid, exclude_locked_outpoints}, payjoin::{PayjoinActor, PayjoinSender, build_sender}, }, mnemonic::{Mnemonic, MnemonicExt as _}, + node::client::NodeClient, transaction::{FeeRate, Transaction, TransactionDetails, TxId}, wallet::Address, }; +#[derive(Debug)] +pub(crate) enum BroadcastTransactionError { + BroadcastFailed(Error), + PostBroadcastFailed(Error), +} + +impl BroadcastTransactionError { + fn into_error(self) -> Error { + match self { + Self::BroadcastFailed(error) | Self::PostBroadcastFailed(error) => error, + } + } +} + impl WalletActor { // Build a transaction but don't advance the change address index #[into_actor_result] @@ -51,7 +67,7 @@ impl WalletActor { exclude_locked_outpoints(&mut tx_builder, locked_outpoints); tx_builder.drain_wallet().drain_to(script_pubkey).fee_rate(fee.into()); - let psbt = tx_builder.finish().map_err(Error::from)?; + let psbt = tx_builder.finish()?; self.wallet.unreserve_tx_change_addresses(&psbt.unsigned_tx); Ok(psbt) @@ -80,7 +96,7 @@ impl WalletActor { tx_builder.add_recipient(script_pubkey, amount); tx_builder.fee_rate(fee_rate); - let psbt = tx_builder.finish().map_err(Error::from)?; + let psbt = tx_builder.finish()?; Ok(psbt) } @@ -117,13 +133,13 @@ impl WalletActor { let send_amount = self.get_max_send_for_utxos(total_amount, &address, fee_rate, &utxos)?; let mut tx_builder = self.wallet.bdk.build_tx(); - tx_builder.add_utxos(&utxos).map_err_str(Error::AddUtxosError)?; + tx_builder.add_utxos(&utxos)?; tx_builder.manually_selected_only(); tx_builder.ordering(TxOrdering::Untouched); tx_builder.add_recipient(address.script_pubkey(), send_amount); tx_builder.fee_rate(fee_rate); - let psbt = tx_builder.finish().map_err(Error::from)?; + let psbt = tx_builder.finish()?; Ok(psbt) } @@ -199,15 +215,15 @@ impl WalletActor { let options = FeeRateOptionsWithTotalFee { fast: FeeRateOptionWithTotalFee::new( fee_rate_options.fast, - fast_psbt.fee().map_err_str(Error::FeesError)?, + fast_psbt.fee().map_err(WalletManagerFeesError::from)?, ), medium: FeeRateOptionWithTotalFee::new( fee_rate_options.medium, - medium_psbt.fee().map_err_str(Error::FeesError)?, + medium_psbt.fee().map_err(WalletManagerFeesError::from)?, ), slow: FeeRateOptionWithTotalFee::new( fee_rate_options.slow, - slow_psbt.fee().map_err_str(Error::FeesError)?, + slow_psbt.fee().map_err(WalletManagerFeesError::from)?, ), custom: None, }; @@ -260,15 +276,15 @@ impl WalletActor { let options = FeeRateOptionsWithTotalFee { fast: FeeRateOptionWithTotalFee::new( fee_rate_options.fast, - fast_psbt.fee().map_err_str(Error::FeesError)?, + fast_psbt.fee().map_err(WalletManagerFeesError::from)?, ), medium: FeeRateOptionWithTotalFee::new( fee_rate_options.medium, - medium_psbt.fee().map_err_str(Error::FeesError)?, + medium_psbt.fee().map_err(WalletManagerFeesError::from)?, ), slow: FeeRateOptionWithTotalFee::new( fee_rate_options.slow, - slow_psbt.fee().map_err_str(Error::FeesError)?, + slow_psbt.fee().map_err(WalletManagerFeesError::from)?, ), custom: None, }; @@ -301,15 +317,15 @@ impl WalletActor { let options_with_fee = FeeRateOptionsWithTotalFee { fast: FeeRateOptionWithTotalFee::new( options.fast, - fast_psbt.fee().map_err_str(Error::FeesError)?, + fast_psbt.fee().map_err(WalletManagerFeesError::from)?, ), medium: FeeRateOptionWithTotalFee::new( options.medium, - medium_psbt.fee().map_err_str(Error::FeesError)?, + medium_psbt.fee().map_err(WalletManagerFeesError::from)?, ), slow: FeeRateOptionWithTotalFee::new( options.slow, - slow_psbt.fee().map_err_str(Error::FeesError)?, + slow_psbt.fee().map_err(WalletManagerFeesError::from)?, ), custom: None, }; @@ -440,8 +456,12 @@ impl WalletActor { match payjoin_endpoint { None => { - let result = self.do_sign_and_broadcast_transaction(psbt).await; - Produces::ok(result) + let (_, transaction) = match self.do_sign_original_psbt(psbt).await { + Ok(result) => result, + Err(error) => return Produces::ok(Err(error)), + }; + + self.start_broadcast_transaction(transaction) } Some(endpoint) => { let result = self.initiate_payjoin_payment(psbt, endpoint).await; @@ -511,55 +531,49 @@ impl WalletActor { Ok((psbt, fallback_tx)) } - async fn do_sign_and_broadcast_transaction(&mut self, psbt: Psbt) -> Result<(), Error> { - let (_, transaction) = self.do_sign_original_psbt(psbt).await?; - - self.broadcast_transaction_to_node(transaction.clone()).await?; - - self.insert_broadcast_transaction(transaction).await; + pub async fn broadcast_transaction( + &mut self, + transaction: BdkTransaction, + ) -> ActorResult> { + if let Err(error) = self.ensure_ledger_ready_for_spend() { + return Produces::ok(Err(error)); + } - Ok(()) + self.start_broadcast_transaction(transaction) } - #[into_actor_result] - pub async fn broadcast_transaction( + fn start_broadcast_transaction( &mut self, transaction: BdkTransaction, - ) -> Result<(), Error> { - self.ensure_ledger_ready_for_spend()?; - self.broadcast_transaction_to_node(transaction.clone()).await?; + ) -> ActorResult> { + let connection = self.deferred_node_connection(); + let (reply, receiver) = futures::channel::oneshot::channel(); - self.insert_broadcast_transaction(transaction).await; + self.addr.send_fut_with(|addr| async move { + let result = broadcast_transaction_with_connection(addr, connection, transaction) + .await + .map_err(BroadcastTransactionError::into_error); + let _ = reply.send(Produces::Value(result)); + }); - Ok(()) + Ok(Produces::Deferred(receiver)) } - async fn broadcast_transaction_to_node( + async fn node_client_for_broadcast(&mut self) -> ActorResult> { + Produces::ok(self.node_client().cloned().map_err(|_| { + Error::SignAndBroadcastError( + "failed to broadcast transaction, could not get node client, try again".to_string(), + ) + })) + } + + async fn apply_broadcast_transaction( &mut self, transaction: BdkTransaction, - ) -> Result<(), Error> { - self.check_node_connection().await.map_err(|error| { - let error_string = - format!("failed to broadcast transaction, unable to connect to node: {error:?}"); - Error::SignAndBroadcastError(error_string) - })?; - - self.node_client() - .await - .map_err(|_| { - Error::SignAndBroadcastError( - "failed to broadcast transaction, could not get node client, try again" - .to_string(), - ) - })? - .broadcast_transaction(transaction.clone()) - .await - .map_err(|error| { - let error_string = format!("failed to broadcast transaction, try again: {error:?}"); - Error::SignAndBroadcastError(error_string) - })?; + ) -> ActorResult> { + self.insert_broadcast_transaction(transaction).await; - Ok(()) + Produces::ok(Ok(())) } #[into_actor_result] @@ -617,19 +631,76 @@ impl WalletActor { send!(self.addr.start_transaction_watcher(txid)); } - async fn broadcast_and_notify(&mut self, tx: BdkTransaction) { - match self.broadcast_transaction_to_node(tx.clone()).await { + fn start_payjoin_broadcast_and_notify(&mut self, tx: BdkTransaction) { + let connection = self.deferred_node_connection(); + + self.addr.send_fut_with(|addr| async move { + let result = broadcast_transaction_with_connection(addr.clone(), connection, tx).await; + send!(addr.handle_payjoin_broadcast_result(result)); + }); + } + + fn start_payjoin_proposal_broadcast( + &mut self, + proposal_tx: BdkTransaction, + fallback_tx: BdkTransaction, + ) { + let connection = self.deferred_node_connection(); + + self.addr.send_fut_with(|addr| async move { + let result = + broadcast_transaction_with_connection(addr.clone(), connection, proposal_tx).await; + send!(addr.handle_payjoin_proposal_broadcast_result(result, fallback_tx)); + }); + } + + async fn handle_payjoin_broadcast_result( + &mut self, + result: Result<(), BroadcastTransactionError>, + ) -> ActorResult<()> { + match result { Ok(()) => { - self.insert_broadcast_transaction(tx).await; self.send(WalletManagerReconcileMessage::PayjoinTxBroadcast); } Err(error) => { + let error = error.into_error(); error!("payjoin broadcast failed: {error}"); self.send(WalletManagerReconcileMessage::SendFlowError( SendFlowErrorAlert::SignAndBroadcast(error.to_string()), )); } } + + self.payjoin_actor = None; + Produces::ok(()) + } + + pub(crate) async fn handle_payjoin_proposal_broadcast_result( + &mut self, + result: Result<(), BroadcastTransactionError>, + fallback_tx: BdkTransaction, + ) -> ActorResult<()> { + match result { + Ok(()) => { + self.send(WalletManagerReconcileMessage::PayjoinTxBroadcast); + self.payjoin_actor = None; + } + Err(BroadcastTransactionError::BroadcastFailed(error)) => { + error!( + "failed to broadcast payjoin proposal, falling back to original tx: {error:?}" + ); + self.start_payjoin_broadcast_and_notify(fallback_tx); + } + Err(BroadcastTransactionError::PostBroadcastFailed(error)) => { + error!("payjoin proposal broadcast bookkeeping failed: {error}"); + self.send(WalletManagerReconcileMessage::SendFlowError( + SendFlowErrorAlert::SignAndBroadcast(error.to_string()), + )); + self.payjoin_actor = None; + } + } + + Produces::ok(()) } pub async fn handle_payjoin_success( @@ -642,25 +713,11 @@ impl WalletActor { error!("failed to sign payjoin proposal, falling back to original tx: {error:?}") }) else { - self.broadcast_and_notify(fallback_tx).await; - self.payjoin_actor = None; + self.start_payjoin_broadcast_and_notify(fallback_tx); return Produces::ok(()); }; - match self.broadcast_transaction_to_node(proposal_tx.clone()).await { - Ok(()) => { - self.insert_broadcast_transaction(proposal_tx).await; - self.send(WalletManagerReconcileMessage::PayjoinTxBroadcast); - } - Err(error) => { - error!( - "failed to broadcast payjoin proposal, falling back to original tx: {error:?}" - ); - self.broadcast_and_notify(fallback_tx).await; - } - } - - self.payjoin_actor = None; + self.start_payjoin_proposal_broadcast(proposal_tx, fallback_tx); Produces::ok(()) } @@ -668,8 +725,7 @@ impl WalletActor { &mut self, fallback_tx: BdkTransaction, ) -> ActorResult<()> { - self.broadcast_and_notify(fallback_tx).await; - self.payjoin_actor = None; + self.start_payjoin_broadcast_and_notify(fallback_tx); Produces::ok(()) } @@ -691,8 +747,7 @@ impl WalletActor { let mut utxo_total_amount = Amount::ZERO; let mut total_fee_amount = Amount::ZERO; - let weighted_utxos = - self.get_weighted_utxos(utxos).map_err_str(Error::AddUtxosError)?; + let weighted_utxos = self.get_weighted_utxos(utxos)?; for weighted_utxo in weighted_utxos { let weight = TxIn::default() @@ -729,7 +784,7 @@ impl WalletActor { } let mut tx_builder = self.wallet.bdk.build_tx(); - tx_builder.add_utxos(utxos).map_err_str(Error::AddUtxosError)?; + tx_builder.add_utxos(utxos)?; tx_builder.manually_selected_only(); tx_builder.ordering(TxOrdering::Untouched); tx_builder.add_recipient(address.script_pubkey(), max_send_estimate); @@ -755,13 +810,13 @@ impl WalletActor { max_send_estimate = reduced_estimate; } - Err(err) => return Err(Error::from(err)), + Err(err) => return Err(err.into()), } } let fee_psbt = fee_psbt.expect("unwrapped in while"); self.wallet.unreserve_tx_change_addresses(&fee_psbt.unsigned_tx); - fee_psbt.fee().map_err_str(Error::BuildTxError)? + fee_psbt.fee().map_err(WalletManagerBuildTxError::from)? }; let max_send_amount = utxo_total_amount.checked_sub(fee).ok_or_else(|| { @@ -845,3 +900,36 @@ impl WalletActor { Some(if block_height > current_height { 0 } else { current_height - block_height + 1 }) } } + +async fn broadcast_transaction_with_connection( + addr: WeakAddr, + connection: Produces>, + transaction: BdkTransaction, +) -> Result<(), BroadcastTransactionError> { + connection + .await + .map_err(|_| BroadcastTransactionError::BroadcastFailed(Error::ActorNotFound))? + .map_err(|error| { + BroadcastTransactionError::BroadcastFailed(Error::SignAndBroadcastError(format!( + "failed to broadcast transaction, unable to connect to node: {error:?}" + ))) + })?; + + let node_client = call!(addr.node_client_for_broadcast()) + .await + .map_err(|_| BroadcastTransactionError::BroadcastFailed(Error::ActorNotFound))? + .map_err(BroadcastTransactionError::BroadcastFailed)?; + + node_client.broadcast_transaction(transaction.clone()).await.map_err(|error| { + BroadcastTransactionError::BroadcastFailed(Error::SignAndBroadcastError(format!( + "failed to broadcast transaction, try again: {error:?}" + ))) + })?; + + call!(addr.apply_broadcast_transaction(transaction)) + .await + .map_err(|_| BroadcastTransactionError::PostBroadcastFailed(Error::ActorNotFound))? + .map_err(BroadcastTransactionError::PostBroadcastFailed)?; + + Ok(()) +} diff --git a/rust/src/manager/wallet_manager/construction.rs b/rust/src/manager/wallet_manager/construction.rs index 5267a4410..1b036d1ad 100644 --- a/rust/src/manager/wallet_manager/construction.rs +++ b/rust/src/manager/wallet_manager/construction.rs @@ -20,9 +20,9 @@ use crate::{ }; use super::{ - DeferredSender, Error, Message, MessageSender, RustWalletManager, SingleOrMany, WalletActor, - WalletBootstrapUnsignedTransactions, WalletLedgerState, WalletScanStatus, WalletSnapshot, - downgrade_and_notify_if_needed, + Error, Message, ReconcileChannel, RustWalletManager, SingleOrMany, WalletActor, + WalletBootstrapUnsignedTransactions, WalletLedgerState, WalletManagerDatabaseCorruptionError, + WalletScanStatus, WalletSnapshot, downgrade_and_notify_if_needed, }; fn start_discovery_scanner( @@ -50,13 +50,12 @@ fn start_discovery_scanner( impl RustWalletManager { #[uniffi::constructor(name = "new")] pub fn try_new(id: WalletId) -> Result { - let (sender, receiver) = flume::bounded(10); + let channel = ReconcileChannel::new(10); let network = Database::global().global_config.selected_network(); let mode = Database::global().global_config.wallet_mode(); - let reconciler = MessageSender::new(sender.clone()); - let mut deferred = DeferredSender::new(reconciler.clone()); + let mut deferred = channel.deferred_sender(); let mut wallet = Wallet::try_load_persisted(id.clone())?; wallet.metadata = downgrade_and_notify_if_needed(wallet.metadata, &mut deferred)?; @@ -90,12 +89,16 @@ impl RustWalletManager { transactions: cached_transactions, })); let unsigned_transactions = WalletBootstrapUnsignedTransactions::database(id.clone()); - let wallet_actor = - WalletActor::new(wallet, sender.clone(), scan_status.clone(), wallet_snapshot.clone()) - .map_err(|e| Error::DatabaseCorruption { id: id.clone(), error: e.to_string() })?; + let wallet_actor = WalletActor::new( + wallet, + channel.raw_sender(), + scan_status.clone(), + wallet_snapshot.clone(), + ) + .map_err(|source| WalletManagerDatabaseCorruptionError::new(id.clone(), source))?; let actor = task::spawn_actor(wallet_actor); - let discovery_scanner = start_discovery_scanner(metadata.clone(), sender); + let discovery_scanner = start_discovery_scanner(metadata.clone(), channel.raw_sender()); let label_manager = LabelManager::new(id.clone()).into(); @@ -103,8 +106,7 @@ impl RustWalletManager { id, actor, metadata: Arc::new(RwLock::new(metadata)), - reconciler, - reconcile_receiver: Arc::new(receiver), + reconciler: channel, scan_status, wallet_snapshot, unsigned_transactions, @@ -115,7 +117,7 @@ impl RustWalletManager { #[uniffi::constructor] pub fn try_new_from_xpub(xpub: String) -> Result { - let (sender, receiver) = flume::bounded(100); + let channel = ReconcileChannel::new(100); let wallet = Wallet::try_new_persisted_from_xpub(xpub)?; let id = wallet.id.clone(); @@ -124,19 +126,22 @@ impl RustWalletManager { let unsigned_transactions = WalletBootstrapUnsignedTransactions::database(id.clone()); let scan_status = Arc::new(RwLock::new(WalletScanStatus::Idle)); - let wallet_actor = - WalletActor::new(wallet, sender.clone(), scan_status.clone(), wallet_snapshot.clone()) - .map_err(|e| Error::DatabaseCorruption { id: id.clone(), error: e.to_string() })?; + let wallet_actor = WalletActor::new( + wallet, + channel.raw_sender(), + scan_status.clone(), + wallet_snapshot.clone(), + ) + .map_err(|source| WalletManagerDatabaseCorruptionError::new(id.clone(), source))?; let actor = task::spawn_actor(wallet_actor); - let discovery_scanner = start_discovery_scanner(metadata.clone(), sender.clone()); + let discovery_scanner = start_discovery_scanner(metadata.clone(), channel.raw_sender()); let label_manager = LabelManager::new(id.clone()).into(); Ok(Self { id, actor, metadata: Arc::new(RwLock::new(metadata)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: channel, scan_status, wallet_snapshot, unsigned_transactions, @@ -152,7 +157,7 @@ impl RustWalletManager { backup: Option>, birthday: Option, ) -> Result { - let (sender, receiver) = flume::bounded(100); + let channel = ReconcileChannel::new(100); let wallet = Wallet::try_new_persisted_from_tap_signer( tap_signer.clone(), @@ -166,9 +171,13 @@ impl RustWalletManager { let unsigned_transactions = WalletBootstrapUnsignedTransactions::database(id.clone()); let scan_status = Arc::new(RwLock::new(WalletScanStatus::Idle)); - let wallet_actor = - WalletActor::new(wallet, sender.clone(), scan_status.clone(), wallet_snapshot.clone()) - .map_err(|e| Error::DatabaseCorruption { id: id.clone(), error: e.to_string() })?; + let wallet_actor = WalletActor::new( + wallet, + channel.raw_sender(), + scan_status.clone(), + wallet_snapshot.clone(), + ) + .map_err(|source| WalletManagerDatabaseCorruptionError::new(id.clone(), source))?; let actor = task::spawn_actor(wallet_actor); let label_manager = LabelManager::new(id.clone()).into(); @@ -176,8 +185,7 @@ impl RustWalletManager { id, actor, metadata: Arc::new(RwLock::new(metadata)), - reconciler: MessageSender::new(sender), - reconcile_receiver: Arc::new(receiver), + reconciler: channel, scan_status, wallet_snapshot, unsigned_transactions, diff --git a/rust/src/manager/wallet_manager/display.rs b/rust/src/manager/wallet_manager/display.rs index e6efb0cb1..a66d67153 100644 --- a/rust/src/manager/wallet_manager/display.rs +++ b/rust/src/manager/wallet_manager/display.rs @@ -152,9 +152,30 @@ impl RustWalletManager { if block_height > current_height { Ok(0) } else { Ok(current_height - block_height + 1) } } + #[uniffi::method] + pub fn display_confirmation_count(&self, confirmations: u32) -> String { + format_confirmation_count(confirmations) + } + #[uniffi::method] pub async fn number_of_confirmations_fmt(&self, block_height: u32) -> Result { let number_of_confirmations = self.number_of_confirmations(block_height).await?; - Ok(number_of_confirmations.thousands_int()) + Ok(format_confirmation_count(number_of_confirmations)) + } +} + +fn format_confirmation_count(confirmations: u32) -> String { + confirmations.thousands_int() +} + +#[cfg(test)] +mod tests { + use super::format_confirmation_count; + + #[test] + fn confirmation_count_formats_with_grouping() { + assert_eq!(format_confirmation_count(0), "0"); + assert_eq!(format_confirmation_count(1), "1"); + assert_eq!(format_confirmation_count(1_000_000), "1,000,000"); } } diff --git a/rust/src/manager/wallet_manager/ledger_state.rs b/rust/src/manager/wallet_manager/ledger_state.rs index 793c39779..a38e6f597 100644 --- a/rust/src/manager/wallet_manager/ledger_state.rs +++ b/rust/src/manager/wallet_manager/ledger_state.rs @@ -2,22 +2,6 @@ use crate::wallet::metadata::WalletMetadata; use super::WalletScanStatus; -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub(crate) enum InitialScanCompletion { - Complete, - Incomplete, -} - -impl From<&WalletMetadata> for InitialScanCompletion { - fn from(metadata: &WalletMetadata) -> Self { - if metadata.internal.performed_full_scan_at.is_some() { - return Self::Complete; - } - - Self::Incomplete - } -} - #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, uniffi::Enum)] pub enum InitialScanActivity { Active, @@ -46,16 +30,15 @@ impl WalletLedgerState { metadata: &WalletMetadata, scan_status: &WalletScanStatus, ) -> Self { - Self::from_parts(metadata.into(), scan_status) + Self::from_parts(metadata.internal.performed_full_scan_at.is_some(), scan_status) } - fn from_parts(completion: InitialScanCompletion, scan_status: &WalletScanStatus) -> Self { - match completion { - InitialScanCompletion::Complete => Self::Complete, - InitialScanCompletion::Incomplete => { - Self::InitialScanIncomplete(InitialScanActivity::from_scan_status(scan_status)) - } + fn from_parts(initial_scan_complete: bool, scan_status: &WalletScanStatus) -> Self { + if initial_scan_complete { + return Self::Complete; } + + Self::InitialScanIncomplete(InitialScanActivity::from_scan_status(scan_status)) } } @@ -79,10 +62,7 @@ mod tests { #[test] fn complete_initial_scan_produces_complete_ledger_state() { - let state = WalletLedgerState::from_parts( - InitialScanCompletion::Complete, - &WalletScanStatus::Scanning(progress()), - ); + let state = WalletLedgerState::from_parts(true, &WalletScanStatus::Scanning(progress())); assert_eq!(state, WalletLedgerState::Complete); } @@ -90,7 +70,7 @@ mod tests { #[test] fn incomplete_initial_scan_preserves_active_scan_activity() { let state = WalletLedgerState::from_parts( - InitialScanCompletion::Incomplete, + false, &WalletScanStatus::ScanningPendingProgress(WalletScanPhase::Full), ); @@ -99,10 +79,7 @@ mod tests { #[test] fn incomplete_initial_scan_preserves_idle_scan_activity() { - let state = WalletLedgerState::from_parts( - InitialScanCompletion::Incomplete, - &WalletScanStatus::Idle, - ); + let state = WalletLedgerState::from_parts(false, &WalletScanStatus::Idle); assert_eq!(state, WalletLedgerState::InitialScanIncomplete(InitialScanActivity::Idle)); } diff --git a/rust/src/node/client_builder.rs b/rust/src/node/client_builder.rs index 55f2f7868..05a524432 100644 --- a/rust/src/node/client_builder.rs +++ b/rust/src/node/client_builder.rs @@ -15,9 +15,4 @@ impl NodeClientBuilder { let node_client = NodeClient::try_from_builder(self).await?; Ok(node_client) } - - pub async fn try_into_client(self) -> Result { - let node_client = NodeClient::new_with_options(&self.node, self.options).await?; - Ok(node_client) - } } diff --git a/rust/src/pending_wallet.rs b/rust/src/pending_wallet.rs index db43b1e89..376082357 100644 --- a/rust/src/pending_wallet.rs +++ b/rust/src/pending_wallet.rs @@ -6,17 +6,15 @@ use crate::{database::Database, mnemonic::NumberOfBip39Words, network::Network}; pub struct PendingWallet { pub mnemonic: Mnemonic, pub network: Network, - #[allow(dead_code)] - pub passphrase: Option, } impl PendingWallet { - pub fn new(number_of_words: NumberOfBip39Words, passphrase: Option) -> Self { + pub fn new(number_of_words: NumberOfBip39Words) -> Self { let network = Database::global().global_config.selected_network(); let mnemonic = number_of_words.generate_mnemonic().clone(); - Self { mnemonic, network, passphrase } + Self { mnemonic, network } } pub fn words(&self) -> Vec { diff --git a/rust/src/tap_card.rs b/rust/src/tap_card.rs index f7e8d7711..00b9e55da 100644 --- a/rust/src/tap_card.rs +++ b/rust/src/tap_card.rs @@ -72,11 +72,6 @@ impl TapcardTransport { pub fn set_message(&self, message: String) { self.0.set_message(message); } - - #[allow(dead_code)] - pub fn append_message(&self, message: String) { - self.0.append_message(message); - } } impl CkTransport for TapcardTransport { diff --git a/rust/src/transaction.rs b/rust/src/transaction.rs index 2d98109a7..2ed050186 100644 --- a/rust/src/transaction.rs +++ b/rust/src/transaction.rs @@ -178,4 +178,12 @@ mod tests { let txid_borrow: &TxId = txid.borrow(); assert_eq!(txid_borrow, &txid); } + + #[test] + fn fee_rate_formats_with_two_decimal_places_and_units() { + assert_eq!(FeeRate::from_sat_per_vb(0.0).sats_per_vbyte_string(), "0.00 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(1.0).sats_per_vbyte_string(), "1.00 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(1.25).sats_per_vbyte_string(), "1.25 sats/vbyte"); + assert_eq!(FeeRate::from_sat_per_vb(12.345).sats_per_vbyte_string(), "12.35 sats/vbyte"); + } } diff --git a/rust/src/transaction/ffi.rs b/rust/src/transaction/ffi.rs index c4c920a44..16375673b 100644 --- a/rust/src/transaction/ffi.rs +++ b/rust/src/transaction/ffi.rs @@ -1,11 +1,10 @@ use cove_nfc::message::NfcMessage; -use cove_util::result_ext::ResultExt as _; +use cove_util::{format::NumberFormatter as _, result_ext::ResultExt as _}; use derive_more::{ AsRef, Deref, derive::{From, Into}, }; use jiff::ToSpan as _; -use numfmt::Formatter; use rand::RngExt as _; use crate::{multi_format::StringOrData, push_tx::PushTx}; @@ -166,10 +165,7 @@ impl ConfirmedTransaction { #[uniffi::method] pub fn block_height_fmt(&self) -> String { - let mut fmt = - Formatter::new().separator(',').unwrap().precision(numfmt::Precision::Decimals(0)); - - fmt.fmt(self.block_height).to_string() + self.block_height.thousands_int() } #[uniffi::method] @@ -298,3 +294,27 @@ fn transaction_preview_unconfirmed_new() -> Transaction { fn random_block_height() -> u32 { rand::rng().random_range(0..850_000) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confirmed_transaction_block_height_formats_with_grouping() { + let mut transaction = ConfirmedTransaction { + txid: TxId::preview_new(), + block_height: 0, + confirmed_at: jiff::Timestamp::now(), + sent_and_received: SentAndReceived::preview_new(), + fiat: None, + labels: Default::default(), + }; + assert_eq!(transaction.block_height_fmt(), "0"); + + transaction.block_height = 1; + assert_eq!(transaction.block_height_fmt(), "1"); + + transaction.block_height = 1_000_000; + assert_eq!(transaction.block_height_fmt(), "1,000,000"); + } +} diff --git a/rust/src/transaction/transaction_details.rs b/rust/src/transaction/transaction_details.rs index d6ac6a7bc..4acdef7f9 100644 --- a/rust/src/transaction/transaction_details.rs +++ b/rust/src/transaction/transaction_details.rs @@ -9,7 +9,6 @@ use bip329::{Label, Labels, TransactionRecord}; use bitcoin::params::Params; use cove_types::Network; use jiff::Timestamp; -use numfmt::{Formatter, Precision}; use crate::{ database::Database, @@ -414,8 +413,7 @@ impl TransactionDetails { #[uniffi::method] pub fn block_number_fmt(&self) -> Option { let block_number = self.block_number()?; - let mut f = Formatter::new().separator(',').unwrap().precision(Precision::Decimals(0)); - Some(f.fmt(block_number).to_string()) + Some(block_number.thousands_int()) } #[uniffi::method] pub fn address_spaced_out(&self) -> Option { @@ -635,4 +633,27 @@ mod tests { metadata.sensitive_visible = false; assert_eq!(details.display_amount(metadata, true), "••••••"); } + + #[test] + fn block_number_formats_with_grouping() { + let mut details = TransactionDetails::preview_new_confirmed(); + + details.pending_or_confirmed = PendingOrConfirmed::Confirmed(ConfirmedDetails { + block_number: 0, + confirmation_time: 0, + }); + assert_eq!(details.block_number_fmt(), Some("0".to_string())); + + details.pending_or_confirmed = PendingOrConfirmed::Confirmed(ConfirmedDetails { + block_number: 1, + confirmation_time: 0, + }); + assert_eq!(details.block_number_fmt(), Some("1".to_string())); + + details.pending_or_confirmed = PendingOrConfirmed::Confirmed(ConfirmedDetails { + block_number: 1_000_000, + confirmation_time: 0, + }); + assert_eq!(details.block_number_fmt(), Some("1,000,000".to_string())); + } } diff --git a/rust/src/wallet.rs b/rust/src/wallet.rs index d3a751d5a..5db37ce96 100644 --- a/rust/src/wallet.rs +++ b/rust/src/wallet.rs @@ -1,5 +1,7 @@ +pub(crate) mod addressing; pub mod amount_display; pub mod balance; +pub(crate) mod builder; pub mod ffi; pub mod fingerprint; pub mod metadata; @@ -7,38 +9,26 @@ pub mod metadata; use std::{str::FromStr as _, sync::Arc}; use crate::{ - app::reconcile::{Update, Updater}, bdk_store::BdkStore, database::{self, Database}, - keychain::{Keychain, KeychainError}, - keys::{Descriptor, Descriptors}, - manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER, - mnemonic::MnemonicExt as _, + keychain::KeychainError, + keys::Descriptor, multi_format::MultiFormatError, tap_card::tap_signer_reader::DeriveInfo, - wallet_identity::{PublicWalletIdentity, existing_public_wallet_by_identity_strict}, - xpub::{self, XpubError}, + xpub::XpubError, }; use balance::Balance; use bdk_wallet::KeychainKind; -use bdk_wallet::bitcoin::bip32::Xpub; use bdk_wallet::chain::rusqlite::Connection; -use bdk_wallet::chain::spk_client::FullScanRequest; -use bdk_wallet::miniscript::descriptor::ShInner; use bip39::Mnemonic; -use cove_bdk::descriptor_ext::DescriptorExt as _; -use cove_common::consts::GAP_LIMIT; -use cove_types::{Network, address::AddressInfoWithDerivation}; +use cove_types::Network; use cove_util::result_ext::ResultExt as _; use eyre::Context as _; -use fingerprint::Fingerprint; -use metadata::{ - DiscoveryState, HardwareWalletMetadata, WalletBirthday, WalletId, WalletMetadata, WalletType, - tap_signer_import_birthday, -}; +use metadata::{WalletBirthday, WalletId, WalletMetadata, WalletType}; use parking_lot::Mutex; -use pubport::formats::Format; -use tracing::{debug, error, warn}; +use tracing::{debug, warn}; + +use builder::{WalletBuilder, WalletSource}; pub use cove_types::address; @@ -169,69 +159,8 @@ impl Wallet { mnemonic: Mnemonic, passphrase: Option, ) -> Result { - let keychain = Keychain::global(); - let database = Database::global(); - - let create_wallet = || -> Result { - // create bdk wallet filestore, set id to metadata id - let me = Self::try_new_persisted_from_mnemonic_segwit( - metadata.clone(), - mnemonic.clone(), - passphrase, - )?; - - // save mnemonic for private key - keychain.save_wallet_key(&me.id, mnemonic.clone())?; - - // save public key in keychain too - let xpub = mnemonic.xpub(me.network.into()); - keychain.save_wallet_xpub(&me.id, xpub)?; - - let (external_descriptor, internal_descriptor) = { - let external_descriptor = me.bdk.public_descriptor(KeychainKind::External); - let internal_descriptor = me.bdk.public_descriptor(KeychainKind::Internal); - - (external_descriptor.clone(), internal_descriptor.clone()) - }; - - // save public descriptors in keychain too - keychain.save_public_descriptor(&me.id, external_descriptor, internal_descriptor)?; - - // save wallet_metadata to database - database.wallets.save_new_wallet_metadata(me.metadata.clone())?; - - // set this wallet as the selected wallet - database.global_config.select_wallet(me.id.clone())?; - - Ok(me) - }; - - // clean up if we fail to create the wallet - let me = match create_wallet() { - Ok(me) => me, - Err(error) => { - error!("failed to create wallet: {error}"); - - // delete the secret key, xpub and public descriptor from the keychain - keychain.delete_wallet_items(&metadata.id); - - if let Err(error) = delete_wallet_specific_data(&metadata.id) { - warn!("clean up failed, failed to delete wallet data: {error}"); - } - - if let Err(error) = database.wallets.delete(&metadata.id) { - warn!("clean up failed, failed to delete wallet: {error}"); - } - - if let Err(error) = database.global_config.clear_selected_wallet() { - warn!("clean up failed, failed to clear selected wallet: {error}"); - } - - return Err(error); - } - }; - - Ok(me) + WalletBuilder::new(WalletSource::PersistedAndSelected { metadata, mnemonic, passphrase }) + .build() } /// Try to load an existing wallet from the persisted bdk wallet filestore @@ -275,124 +204,12 @@ impl Wallet { /// Create a new watch-only wallet from the given xpub pub fn try_new_persisted_from_xpub(xpub: String) -> Result { - let xpub = xpub.trim(); - let hardware_export = pubport::Format::try_new_from_str(xpub) - .map_err(Into::into) - .map_err(WalletError::ParseXpubError); - - if let Ok(hardware_export) = hardware_export { - return Self::try_new_persisted_from_pubport(hardware_export); - } - - // already returned if its a valid xpub - Err(hardware_export.unwrap_err()) + WalletBuilder::new(WalletSource::Xpub(xpub)).build() } /// Import from a hardware export pub fn try_new_persisted_from_pubport(pubport: pubport::Format) -> Result { - let keychain = Keychain::global(); - let database = Database::global(); - let network = database.global_config.selected_network(); - let mode = database.global_config.wallet_mode(); - - let id = WalletId::new(); - let mut metadata = WalletMetadata::new_for_hardware(id.clone(), "", None); - - let pubport_descriptors = match pubport { - Format::Descriptor(descriptors) => descriptors, - Format::Json(json) => { - let (descriptors, address_type) = preferred_json_descriptors(&json)?; - - if should_start_json_discovery(&json, address_type) { - metadata.discovery_state = - DiscoveryState::StartedJson(Arc::new((*json).into())); - } - - descriptors - } - Format::Wasabi(descriptors) => descriptors, - Format::Electrum(descriptors) => descriptors, - Format::KeyExpression(descriptors) => descriptors, - }; - - // compute xpub and descriptors early so they're available for the upgrade path - let descriptors: Descriptors = pubport_descriptors.into(); - metadata.address_type = address_type_from_descriptors(&descriptors)?; - let fingerprint = descriptors.fingerprint(); - let xpub = xpub_from_descriptors(&descriptors)?; - - let incoming_identity = PublicWalletIdentity::from_descriptors(&descriptors); - - // check for existing wallet with same public identity, upgrade watch-only to cold - if let Some(fingerprint) = fingerprint.as_ref() { - let fingerprint: Fingerprint = (*fingerprint).into(); - metadata.master_fingerprint = Some(fingerprint.into()); - - let existing = existing_public_wallet_by_identity_strict( - &database, - keychain, - network, - mode, - fingerprint, - &incoming_identity, - ) - .map_err_str(WalletError::LoadError)?; - - if let Some(existing_metadata) = existing { - return Self::upgrade_to_cold( - existing_metadata, - &metadata, - xpub, - descriptors, - keychain, - &database, - ); - } - } - - let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; - - let fingerprint = fingerprint.map(|s| s.to_string()); - - metadata.name = match &fingerprint { - Some(fingerprint) => format!("Imported {}", fingerprint.to_ascii_uppercase()), - None => "Imported XPub".to_string(), - }; - - metadata.wallet_type = match &fingerprint { - Some(_) => WalletType::Cold, - None => WalletType::XpubOnly, - }; - - // get origin only if its not a watch only wallet - match metadata.wallet_type { - WalletType::Hot | WalletType::Cold => { - metadata.origin = descriptors.origin().ok(); - } - _ => {} - } - - let wallet = descriptors - .clone() - .into_create_params() - .network(network.into()) - .create_wallet(&mut store.conn) - .map_err_str(WalletError::BdkError)?; - - // save public key in keychain too - keychain.save_wallet_xpub(&id, xpub)?; - - // save public descriptor in keychain too - keychain.save_public_descriptor( - &metadata.id, - descriptors.external.extended_descriptor, - descriptors.internal.extended_descriptor, - )?; - - database.wallets.save_new_wallet_metadata(metadata.clone())?; - CLOUD_BACKUP_MANAGER.handle_wallet_set_change(); - - Ok(Self { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) + WalletBuilder::new(WalletSource::Pubport(Box::new(pubport))).build() } pub fn try_new_persisted_from_tap_signer( @@ -401,135 +218,7 @@ impl Wallet { backup: Option>, birthday: Option, ) -> Result { - let keychain = Keychain::global(); - let database = Database::global(); - let mode = database.global_config.wallet_mode(); - let network = database.global_config.selected_network(); - assert!(network == derive.network); - - let id = WalletId::new(); - - let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; - - let descriptors = Descriptors::new_from_tap_signer(&derive) - .map_err_str(WalletError::DescriptorKeyParseError)?; - - let fingerprint = Fingerprint::from(derive.master_fingerprint()); - - // set metadata - let mut metadata = WalletMetadata::new_for_hardware(id.clone(), "", None); - metadata.name = "TAPSIGNER".to_string(); - metadata.wallet_mode = mode; - metadata.hardware_metadata = Some(HardwareWalletMetadata::TapSigner(tap_signer)); - metadata.origin = descriptors.origin().ok(); - metadata.master_fingerprint = Some(Arc::new(fingerprint)); - metadata.wallet_type = WalletType::Cold; - metadata.birthday = - birthday.or_else(|| tap_signer_import_birthday(network, derive.birth_height)); - - // make sure its not already imported - check_for_duplicate_wallet(network, mode, fingerprint)?; - - let xpub = - descriptors.external.xpub().expect("tap_signer descriptor always made with xpub"); - - let wallet = descriptors - .clone() - .into_create_params() - .network(network.into()) - .create_wallet(&mut store.conn) - .map_err_str(WalletError::BdkError)?; - - // save public key in keychain too - keychain.save_wallet_xpub(&id, xpub)?; - - // save public descriptor in keychain too - keychain.save_public_descriptor( - &metadata.id, - descriptors.external.extended_descriptor, - descriptors.internal.extended_descriptor, - )?; - - // if theres a backup for this wallet, save it in the keychain - if let Some(backup) = backup { - keychain.save_tap_signer_backup(&id, backup.as_slice())?; - } - - database.wallets.save_new_wallet_metadata(metadata.clone())?; - CLOUD_BACKUP_MANAGER.handle_wallet_set_change(); - - Ok(Self { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) - } - - /// The user imported a hww and wants to switch from native segwit to a different address type - pub fn switch_descriptor_to_new_address_type( - &mut self, - descriptors: pubport::descriptor::Descriptors, - address_type: WalletAddressType, - ) -> Result<(), WalletError> { - debug!("switching public descriptor wallet to new address type"); - - let id = self.id.clone(); - - // delete the bdk wallet filestore - BdkStore::delete_sqlite_store(&self.id).map_err(|error| { - WalletError::PersistError(format!("failed to delete wallet filestore: {error}")) - })?; - - let store = BdkStore::try_new(&id, self.network); - let mut db = store.map_err_str(WalletError::LoadError)?.conn; - - let descriptors: Descriptors = descriptors.into(); - let wallet = descriptors - .into_create_params() - .network(self.network.into()) - .create_wallet(&mut db) - .map_err_str(WalletError::BdkError)?; - - // switch db and wallet - self.bdk = wallet; - self.db = Mutex::new(db); - let metadata = self.current_database_metadata()?; - let metadata = metadata_for_address_type_switch(metadata, address_type); - self.persist_address_type_switch_metadata(metadata)?; - - Ok(()) - } - - /// The user imported a hot wallet and wants to switch from native segwit to a different address type - pub fn switch_mnemonic_to_new_address_type( - &mut self, - address_type: WalletAddressType, - ) -> Result<(), WalletError> { - debug!("switching mnemonic wallet to new address type"); - - // delete the bdk wallet filestore - BdkStore::delete_sqlite_store(&self.id).map_err(|error| { - WalletError::PersistError(format!("failed to delete wallet filestore: {error}")) - })?; - - let mnemonic = Keychain::global() - .get_wallet_key(&self.id) - .ok() - .flatten() - .ok_or(WalletError::WalletNotFound)?; - - let metadata_for_new_wallet = self.current_database_metadata()?; - let mut me = Self::try_new_persisted_from_mnemonic( - metadata_for_new_wallet, - mnemonic, - None, - address_type, - )?; - let current_metadata = self.current_database_metadata()?; - let metadata = - metadata_for_mnemonic_address_type_switch(current_metadata, &me.metadata, address_type); - - // swap the wallet to the new one - std::mem::swap(&mut me, self); - self.persist_address_type_switch_metadata(metadata)?; - - Ok(()) + WalletBuilder::new(WalletSource::TapSigner { tap_signer, derive, backup, birthday }).build() } fn try_new_persisted_from_mnemonic_segwit( @@ -546,39 +235,19 @@ impl Wallet { } fn try_new_persisted_from_mnemonic( - mut metadata: WalletMetadata, + metadata: WalletMetadata, mnemonic: Mnemonic, passphrase: Option, address_type: WalletAddressType, ) -> Result { - let network = Database::global().global_config.selected_network(); - - let id = metadata.id.clone(); - let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; - - let descriptors = mnemonic.into_descriptors(passphrase, network, address_type); - let origin = descriptors.origin().ok(); - - metadata.master_fingerprint = descriptors.fingerprint().map(|f| Arc::new(f.into())); - metadata.origin = origin; - - let wallet = descriptors - .into_create_params() - .network(network.into()) - .create_wallet(&mut store.conn) - .map_err_str(WalletError::BdkError)?; - - Ok(Self { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) + WalletBuilder::new(WalletSource::Mnemonic { metadata, mnemonic, passphrase, address_type }) + .build() } pub fn balance(&self) -> Balance { self.bdk.balance().into() } - pub(crate) fn start_receive_prioritized_full_scan(&self) -> FullScanRequest { - receive_prioritized_full_scan_request(&self.bdk) - } - /// Read cached transactions from the BDK wallet pub fn transactions(&self) -> Vec { use crate::transaction::{Amount, Transaction}; @@ -600,276 +269,11 @@ impl Wallet { transactions } - pub fn get_next_address(&mut self) -> Result { - const MAX_ADDRESSES: usize = (GAP_LIMIT - 5) as usize; - - let addresses: Vec = self - .bdk - .list_unused_addresses(KeychainKind::External) - .take(MAX_ADDRESSES) - .map(Into::into) - .collect(); - - // get up to 25 revealed but unused addresses - if addresses.len() < MAX_ADDRESSES { - let address_info = - AddressInfo::from(self.bdk.reveal_next_address(KeychainKind::External)); - - self.persist()?; - - let derivation_path = - self.bdk.public_descriptor(KeychainKind::External).derivation_path().ok(); - let info = AddressInfoWithDerivation::new(address_info, derivation_path); - return Ok(info); - } - - // if we have already revealed 25 addresses, we cycle back to the first one - // and present those addresses, until a next unused address is available, if we don't - // do this we could hit the gap limit and users might use a an adddress past - // the gap limit and not be able to see it their wallet - // - // note: index to use is the index of the address in the list of addresses, not the derivation index - let index_to_use = - if let Some(last_index) = self.metadata.internal.last_seen_address_index(&addresses) { - (last_index + 1) % MAX_ADDRESSES - } else { - 0 - }; - - let address_info = addresses[index_to_use].clone(); - self.metadata.internal.set_last_seen_address_index(&addresses, index_to_use); - - Database::global().wallets.update_internal_metadata(&self.metadata)?; - - let public_descriptor = self.bdk.public_descriptor(KeychainKind::External); - let derivation_path = public_descriptor.derivation_path().ok(); - let address_info_with_derivation = - AddressInfoWithDerivation::new(address_info, derivation_path); - - Ok(address_info_with_derivation) - } - - pub fn receive_address_at_index(&self, index: u32) -> AddressInfoWithDerivation { - let address_info = AddressInfo::from(self.bdk.peek_address(KeychainKind::External, index)); - let public_descriptor = self.bdk.public_descriptor(KeychainKind::External); - let derivation_path = public_descriptor.derivation_path().ok(); - - AddressInfoWithDerivation::new(address_info, derivation_path) - } - - pub fn receive_address_is_unused(&self, index: u32) -> bool { - self.bdk.list_unused_addresses(KeychainKind::External).any(|address| address.index == index) - } - - pub fn mark_receive_address_used(&mut self, index: u32) -> Result<(), WalletError> { - if self.bdk.mark_used(KeychainKind::External, index) { - self.persist()?; - } - - Ok(()) - } - pub fn persist(&mut self) -> Result<(), WalletError> { self.bdk.persist(&mut self.db.lock()).map_err_str(WalletError::PersistError)?; Ok(()) } - - pub fn unreserve_tx_change_addresses(&mut self, tx: &bdk_wallet::bitcoin::Transaction) { - for txout in &tx.output { - if let Some((KeychainKind::Internal, index)) = - self.bdk.derivation_of_spk(txout.script_pubkey.clone()) - { - self.bdk.unmark_used(KeychainKind::Internal, index); - } - } - } - - /// Upgrade an existing watch-only wallet to cold by saving the xpub and descriptors - fn upgrade_to_cold( - mut metadata: WalletMetadata, - import_metadata: &WalletMetadata, - xpub: Xpub, - descriptors: Descriptors, - keychain: &Keychain, - database: &Database, - ) -> Result { - if metadata.wallet_type != WalletType::WatchOnly { - return Err(WalletError::WalletAlreadyExists(metadata.id)); - } - - let id = metadata.id.clone(); - keychain.save_wallet_xpub(&id, xpub)?; - - metadata = metadata_for_cold_upgrade(metadata, import_metadata, &descriptors); - - keychain.save_public_descriptor( - &id, - descriptors.external.extended_descriptor, - descriptors.internal.extended_descriptor, - )?; - - database.wallets.update_wallet_metadata(metadata)?; - database.global_config.select_wallet(id.clone())?; - - Updater::send_update(Update::ClearCachedWalletManager(id.clone())); - CLOUD_BACKUP_MANAGER.handle_wallet_backup_change_and_reverify(id.clone()); - Self::try_load_persisted(id) - } -} - -fn check_for_duplicate_wallet( - network: Network, - mode: metadata::WalletMode, - fingerprint: Fingerprint, -) -> Result<(), WalletError> { - let all_fingerprints: Vec<(WalletId, Arc)> = Database::global() - .wallets - .get_all(network, mode) - .map(|wallets| { - wallets - .into_iter() - .filter_map(|wallet_metadata| { - let fingerprint = wallet_metadata.master_fingerprint?; - Some((wallet_metadata.id, fingerprint)) - }) - .collect() - }) - .unwrap_or_default(); - - if let Some((id, _)) = all_fingerprints.into_iter().find(|(_, f)| f.as_ref() == &fingerprint) { - return Err(WalletError::WalletAlreadyExists(id)); - } - - Ok(()) -} - -fn metadata_for_cold_upgrade( - mut existing_metadata: WalletMetadata, - import_metadata: &WalletMetadata, - descriptors: &Descriptors, -) -> WalletMetadata { - existing_metadata.wallet_type = WalletType::Cold; - existing_metadata.origin = descriptors.origin().ok(); - existing_metadata.address_type = import_metadata.address_type; - - if import_metadata.discovery_state != DiscoveryState::Single { - existing_metadata.discovery_state = import_metadata.discovery_state.clone(); - } - - existing_metadata -} - -/// Builds an incremental scan request that checks revealed-unused receive addresses first -/// -/// The request still uses unbounded BDK SPK iterators. The progressive scanner owns stop-gap -/// enforcement, so the normal external iterator resumes from index `0` with prioritized indexes -/// filtered out instead of being capped to the gap limit -fn receive_prioritized_full_scan_request( - wallet: &bdk_wallet::Wallet, -) -> FullScanRequest { - let mut builder = FullScanRequest::builder().chain_tip(wallet.local_chain().tip()); - - let priority_spks = wallet - .list_unused_addresses(KeychainKind::External) - .take(GAP_LIMIT as usize) - .map(|address| (address.index, address.address.script_pubkey())) - .collect::>(); - - let priority_indices = priority_spks.iter().map(|(index, _)| *index).collect::>(); - - if let Some(external_spks) = wallet.spk_index().unbounded_spk_iter(KeychainKind::External) { - let external_spks = priority_spks - .into_iter() - .chain(external_spks.filter(move |(index, _)| !priority_indices.contains(index))); - - builder = builder.spks_for_keychain(KeychainKind::External, external_spks); - } - - if let Some(internal_spks) = wallet.spk_index().unbounded_spk_iter(KeychainKind::Internal) { - builder = builder.spks_for_keychain(KeychainKind::Internal, internal_spks); - } - - builder.build() -} - -/// Selects the JSON descriptor set that becomes the imported wallet -/// -/// A JSON export can carry several standard account types. Cove imports the most modern -/// supported primary wallet and lets discovery scan any supported older alternates separately -fn preferred_json_descriptors( - json: &pubport::formats::Json, -) -> Result<(pubport::descriptor::Descriptors, WalletAddressType), WalletError> { - [ - (&json.bip84, WalletAddressType::NativeSegwit), - (&json.bip49, WalletAddressType::WrappedSegwit), - (&json.bip44, WalletAddressType::Legacy), - ] - .into_iter() - .find_map(|(descriptors, address_type)| { - descriptors.as_ref().map(|descriptors| (descriptors.clone(), address_type)) - }) - .ok_or_else(|| { - WalletError::ParseXpubError(xpub::XpubError::MissingXpub( - "No supported BIP44, BIP49, or BIP84 xpub found".to_string(), - )) - }) -} - -fn xpub_from_descriptors(descriptors: &Descriptors) -> Result { - descriptors.external.xpub().ok_or(WalletError::ParseXpubError(XpubError::InvalidDescriptor( - xpub::DescriptorError::NoXpubInDescriptor, - ))) -} - -fn address_type_from_descriptors( - descriptors: &Descriptors, -) -> Result { - let external = address_type_from_descriptor(&descriptors.external)?; - let internal = address_type_from_descriptor(&descriptors.internal)?; - - if external != internal { - return Err(WalletError::UnsupportedWallet( - "external and internal descriptors use different address types".to_string(), - )); - } - - Ok(external) -} - -fn address_type_from_descriptor(descriptor: &Descriptor) -> Result { - match &descriptor.extended_descriptor { - bdk_wallet::miniscript::Descriptor::Pkh(_) => Ok(WalletAddressType::Legacy), - bdk_wallet::miniscript::Descriptor::Wpkh(_) => Ok(WalletAddressType::NativeSegwit), - bdk_wallet::miniscript::Descriptor::Sh(sh) => match sh.as_inner() { - ShInner::Wpkh(_) => Ok(WalletAddressType::WrappedSegwit), - _ => Err(unsupported_descriptor_address_type("non-wrapped-SegWit P2SH")), - }, - bdk_wallet::miniscript::Descriptor::Tr(_) => { - Err(unsupported_descriptor_address_type("Taproot")) - } - _ => Err(unsupported_descriptor_address_type("this descriptor type")), - } -} - -fn unsupported_descriptor_address_type(name: &str) -> WalletError { - WalletError::UnsupportedWallet(format!("{name} descriptors are not supported")) -} - -/// Returns whether a JSON import needs alternate address-type discovery -/// -/// Discovery only starts when the export has a supported alternate that differs from the -/// primary wallet, because the primary descriptor is synced by the main wallet -/// -/// Native SegWit (`bip84`) is intentionally absent because it is the preferred primary -/// descriptor when present, not an alternate discovery target -fn should_start_json_discovery( - json: &pubport::formats::Json, - address_type: WalletAddressType, -) -> bool { - [(&json.bip49, WalletAddressType::WrappedSegwit), (&json.bip44, WalletAddressType::Legacy)] - .into_iter() - .any(|(descriptors, type_)| descriptors.is_some() && type_ != address_type) } impl Wallet { @@ -913,27 +317,6 @@ impl WalletAddressType { } } -fn metadata_for_address_type_switch( - mut metadata: WalletMetadata, - address_type: WalletAddressType, -) -> WalletMetadata { - metadata.address_type = address_type; - metadata.discovery_state = DiscoveryState::ChoseAdressType; - metadata.internal.reset_scan_state_for_address_type_switch(); - metadata -} - -fn metadata_for_mnemonic_address_type_switch( - current_metadata: WalletMetadata, - derived_metadata: &WalletMetadata, - address_type: WalletAddressType, -) -> WalletMetadata { - let mut metadata = metadata_for_address_type_switch(current_metadata, address_type); - metadata.master_fingerprint = derived_metadata.master_fingerprint.clone(); - metadata.origin = derived_metadata.origin.clone(); - metadata -} - // delete wallet filestore / sqlite store and wallet data database pub fn delete_wallet_specific_data(wallet_id: &WalletId) -> eyre::Result<()> { BdkStore::delete_wallet_stores(wallet_id)?; @@ -943,595 +326,6 @@ pub fn delete_wallet_specific_data(wallet_id: &WalletId) -> eyre::Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - use bdk_wallet::bitcoin::{ - Address as BdkAddress, Amount, BlockHash, Network, hashes::Hash as _, - }; - use bdk_wallet::chain::{BlockId, ConfirmationBlockTime}; - use bdk_wallet::test_utils::{ - get_funded_wallet_wpkh, get_test_wpkh_and_change_desc, insert_anchor, insert_checkpoint, - insert_tx, - }; - - const BIP49_YPUB: &str = "ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"; - const BIP84_ZPUB: &str = "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"; - - fn test_bdk_wallet() -> bdk_wallet::Wallet { - let (external_descriptor, internal_descriptor) = get_test_wpkh_and_change_desc(); - - bdk_wallet::Wallet::create(external_descriptor, internal_descriptor) - .network(Network::Regtest) - .create_wallet_no_persist() - .expect("wallet is created") - } - - fn pubport_descriptors(descriptor: &str) -> pubport::descriptor::Descriptors { - pubport::descriptor::Descriptors::try_from_line(descriptor) - .expect("descriptor fixture is valid") - } - - fn descriptor_json( - bip44: Option, - bip49: Option, - bip84: Option, - ) -> pubport::formats::Json { - pubport::formats::Json { bip44, bip49, bip84, bip86: None } - } - - fn json_from_export(export: &str) -> pubport::formats::Json { - let format = pubport::Format::try_new_from_str(export).expect("export fixture is valid"); - let pubport::Format::Json(json) = format else { - panic!("Expected JSON export"); - }; - - *json - } - - fn bip44_descriptors() -> pubport::descriptor::Descriptors { - pubport_descriptors( - "pkh([817e7be0/44h/0h/0h]xpub6BoKN14JzSFN1T3cqe9FnrwnXGAsmbgETJyeazoa3F7aMXh4XndvVrJAYyM127FsrH8KFv5XFXDroqXNfZMfsinow7xp93ueYSpnrjBBFs4/<0;1>/*)#tdtrl3y9", - ) - } - - fn bip49_descriptors() -> pubport::descriptor::Descriptors { - pubport_descriptors( - "sh(wpkh([817e7be0/49h/0h/0h]xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm/<0;1>/*))#8llmt36x", - ) - } - - fn bip84_descriptors() -> pubport::descriptor::Descriptors { - pubport_descriptors( - "wpkh([817e7be0/84h/0h/0h]xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM/<0;1>/*)#60tjs4c7", - ) - } - - fn bip86_descriptors() -> pubport::descriptor::Descriptors { - pubport_descriptors( - "tr([817e7be0/86h/0h/0h]xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM/<0;1>/*)", - ) - } - - fn assert_missing_supported_xpub(error: WalletError) { - let WalletError::ParseXpubError(xpub::XpubError::MissingXpub(message)) = error else { - panic!("expected missing xpub error"); - }; - - assert_eq!(message, "No supported BIP44, BIP49, or BIP84 xpub found"); - } - - #[test] - fn preferred_json_descriptors_uses_native_segwit_when_present() { - let json = descriptor_json( - Some(bip44_descriptors()), - Some(bip49_descriptors()), - Some(bip84_descriptors()), - ); - - let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::NativeSegwit); - assert!(descriptors.external.to_string().starts_with("wpkh(")); - } - - #[test] - fn preferred_json_descriptors_falls_back_to_wrapped_segwit() { - let json = descriptor_json(Some(bip44_descriptors()), Some(bip49_descriptors()), None); - - let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::WrappedSegwit); - assert!(descriptors.external.to_string().starts_with("sh(wpkh(")); - } - - #[test] - fn preferred_json_descriptors_falls_back_to_legacy() { - let json = descriptor_json(Some(bip44_descriptors()), None, None); - - let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::Legacy); - assert!(descriptors.external.to_string().starts_with("pkh(")); - } - - #[test] - fn preferred_json_descriptors_errors_without_supported_xpub() { - let json = descriptor_json(None, None, None); - let error = preferred_json_descriptors(&json).unwrap_err(); - - assert_missing_supported_xpub(error); - } - - #[test] - fn preferred_json_descriptors_rejects_bip86_only_export() { - let json = json_from_export( - r#"{ - "xfp": "817e7be0", - "bip86": { - "deriv": "m/86'/0'/0'", - "xpub": "xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM", - "first": "bc1p5cyxnuxmeuwuvkwfem96l9z7k3d5en0fhzc3wkvsgq4wv5q3xpqsv0gz6u" - } -}"#, - ); - let error = preferred_json_descriptors(&json).unwrap_err(); - - assert_missing_supported_xpub(error); - } - - #[test] - fn bare_ypub_import_selects_wrapped_segwit_descriptor() { - let json = json_from_export(BIP49_YPUB); - - assert!(json.bip49.is_some()); - assert!(json.bip84.is_none()); - - let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::WrappedSegwit); - assert!(descriptors.external.to_string().starts_with("sh(wpkh(")); - assert!(!should_start_json_discovery(&json, address_type)); - } - - #[test] - fn wrapped_segwit_json_import_material_exposes_nested_xpub() { - let json = descriptor_json(None, Some(bip49_descriptors()), None); - - let (pubport_descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - let descriptors: Descriptors = pubport_descriptors.into(); - - assert_eq!(address_type, WalletAddressType::WrappedSegwit); - assert_eq!( - descriptors.fingerprint().map(|fingerprint| fingerprint.to_string()), - Some("817e7be0".to_string()) - ); - assert_eq!( - xpub_from_descriptors(&descriptors).unwrap().to_string(), - "xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm" - ); - } - - #[test] - fn converted_json_import_material_matches_pubport_for_primary_descriptors() { - for pubport_descriptors in [bip84_descriptors(), bip44_descriptors()] { - let descriptors: Descriptors = pubport_descriptors.clone().into(); - - assert_eq!(descriptors.fingerprint(), pubport_descriptors.fingerprint()); - assert_eq!( - xpub_from_descriptors(&descriptors).unwrap(), - pubport_descriptors.xpub().unwrap() - ); - } - } - - #[test] - fn descriptor_address_type_infers_supported_descriptor_formats() { - for (pubport_descriptors, expected) in [ - (bip84_descriptors(), WalletAddressType::NativeSegwit), - (bip49_descriptors(), WalletAddressType::WrappedSegwit), - (bip44_descriptors(), WalletAddressType::Legacy), - ] { - let descriptors = Descriptors::from(pubport_descriptors); - - assert_eq!(address_type_from_descriptors(&descriptors).unwrap(), expected); - } - } - - #[test] - fn descriptor_address_type_rejects_taproot() { - let descriptors = Descriptors::from(bip86_descriptors()); - let error = address_type_from_descriptors(&descriptors).unwrap_err(); - - assert_eq!( - error, - WalletError::UnsupportedWallet("Taproot descriptors are not supported".to_string()) - ); - } - - #[test] - fn bare_zpub_import_selects_native_segwit_descriptor() { - let json = json_from_export(BIP84_ZPUB); - - assert!(json.bip84.is_some()); - assert!(json.bip49.is_none()); - - let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::NativeSegwit); - assert!(descriptors.external.to_string().starts_with("wpkh(")); - assert!(!should_start_json_discovery(&json, address_type)); - } - - #[test] - fn json_discovery_starts_for_native_segwit_with_alternates() { - let json = descriptor_json( - Some(bip44_descriptors()), - Some(bip49_descriptors()), - Some(bip84_descriptors()), - ); - - let (_, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::NativeSegwit); - assert!(should_start_json_discovery(&json, address_type)); - } - - #[test] - fn json_discovery_starts_for_wrapped_segwit_with_legacy_alternate() { - let json = descriptor_json(Some(bip44_descriptors()), Some(bip49_descriptors()), None); - - let (_, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::WrappedSegwit); - assert!(should_start_json_discovery(&json, address_type)); - } - - #[test] - fn json_discovery_does_not_start_for_single_native_segwit_export() { - let json = descriptor_json(None, None, Some(bip84_descriptors())); - - let (_, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::NativeSegwit); - assert!(!should_start_json_discovery(&json, address_type)); - } - - #[test] - fn json_discovery_does_not_start_for_wrapped_segwit_export() { - let json = descriptor_json(None, Some(bip49_descriptors()), None); - - let (_, address_type) = preferred_json_descriptors(&json).unwrap(); - - assert_eq!(address_type, WalletAddressType::WrappedSegwit); - assert!(!should_start_json_discovery(&json, address_type)); - } - - #[test] - fn cold_upgrade_metadata_carries_json_discovery_state() { - let json = descriptor_json( - Some(bip44_descriptors()), - Some(bip49_descriptors()), - Some(bip84_descriptors()), - ); - let descriptors = Descriptors::from(bip84_descriptors()); - - let mut existing_metadata = WalletMetadata::preview_new(); - existing_metadata.name = "Existing watch-only wallet".to_string(); - existing_metadata.wallet_type = WalletType::WatchOnly; - existing_metadata.discovery_state = DiscoveryState::Single; - existing_metadata.address_type = WalletAddressType::Legacy; - - let mut import_metadata = existing_metadata.clone(); - import_metadata.name = "Incoming hardware wallet".to_string(); - import_metadata.wallet_type = WalletType::Cold; - import_metadata.address_type = WalletAddressType::NativeSegwit; - import_metadata.discovery_state = - DiscoveryState::StartedJson(Arc::new(json.clone().into())); - - let updated = - metadata_for_cold_upgrade(existing_metadata.clone(), &import_metadata, &descriptors); - - assert_eq!(updated.name, existing_metadata.name); - assert_ne!(updated.name, import_metadata.name); - assert_eq!(updated.wallet_type, WalletType::Cold); - assert_eq!(updated.address_type, WalletAddressType::NativeSegwit); - assert_eq!(updated.origin, descriptors.origin().ok()); - - let DiscoveryState::StartedJson(found_json) = updated.discovery_state else { - panic!("expected JSON discovery to start after cold upgrade"); - }; - - assert_eq!(found_json.as_ref().0, json); - } - - fn scan_indexes( - request: &mut FullScanRequest, - keychain: KeychainKind, - count: usize, - ) -> Vec { - request.iter_spks(keychain).take(count).map(|(index, _)| index).collect() - } - - fn build_tx_with_change(wallet: &mut bdk_wallet::Wallet) -> bdk_wallet::bitcoin::Psbt { - let address = BdkAddress::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") - .unwrap() - .require_network(Network::Regtest) - .unwrap(); - - let mut builder = wallet.build_tx(); - builder.add_recipient(address.script_pubkey(), Amount::from_sat(10_000)); - builder.fee_absolute(Amount::from_sat(1_000)); - builder.finish().unwrap() - } - - fn tx_output_index( - wallet: &bdk_wallet::Wallet, - tx: &bdk_wallet::bitcoin::Transaction, - keychain: KeychainKind, - ) -> u32 { - tx.output - .iter() - .find_map(|txout| match wallet.derivation_of_spk(txout.script_pubkey.clone()) { - Some((txout_keychain, index)) if txout_keychain == keychain => Some(index), - _ => None, - }) - .unwrap() - } - - fn unused_addresses_contain( - wallet: &bdk_wallet::Wallet, - keychain: KeychainKind, - index: u32, - ) -> bool { - wallet.list_unused_addresses(keychain).any(|address| address.index == index) - } - - fn unreserve_tx_change_addresses( - wallet: &mut bdk_wallet::Wallet, - tx: &bdk_wallet::bitcoin::Transaction, - ) { - for txout in &tx.output { - if let Some((KeychainKind::Internal, index)) = - wallet.derivation_of_spk(txout.script_pubkey.clone()) - { - wallet.unmark_used(KeychainKind::Internal, index); - } - } - } - - #[test] - fn unreserve_tx_change_addresses_releases_reserved_change_index() { - let (mut wallet, _) = get_funded_wallet_wpkh(); - let psbt = build_tx_with_change(&mut wallet); - let change_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::Internal); - - assert!(!unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); - - unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); - - assert!(unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); - } - - #[test] - fn unreserve_tx_change_addresses_keeps_confirmed_change_index_used() { - let (mut wallet, _) = get_funded_wallet_wpkh(); - let psbt = build_tx_with_change(&mut wallet); - let change_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::Internal); - let block_id = BlockId { height: 1, hash: BlockHash::hash(b"confirmed change") }; - let confirmation = ConfirmationBlockTime { block_id, confirmation_time: 1 }; - - insert_checkpoint(&mut wallet, block_id); - insert_tx(&mut wallet, psbt.unsigned_tx.clone()); - insert_anchor(&mut wallet, psbt.unsigned_tx.compute_txid(), confirmation); - - unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); - - assert!(!unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); - } - - #[test] - fn unreserve_tx_change_addresses_keeps_self_send_receive_index_used() { - let (mut wallet, _) = get_funded_wallet_wpkh(); - let receive_address = wallet.reveal_next_address(KeychainKind::External); - - assert!(wallet.mark_used(KeychainKind::External, receive_address.index)); - - let mut builder = wallet.build_tx(); - builder.add_recipient(receive_address.address.script_pubkey(), Amount::from_sat(10_000)); - builder.fee_absolute(Amount::from_sat(1_000)); - - let psbt = builder.finish().unwrap(); - let receive_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::External); - - assert_eq!(receive_address.index, receive_index); - assert!(!unused_addresses_contain(&wallet, KeychainKind::External, receive_index)); - - unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); - - assert!(!unused_addresses_contain(&wallet, KeychainKind::External, receive_index)); - } - - #[test] - fn receive_prioritized_scan_checks_revealed_unused_external_indexes_first() { - let mut wallet = test_bdk_wallet(); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 4).last(); - assert!(wallet.mark_used(KeychainKind::External, 0)); - assert!(wallet.mark_used(KeychainKind::External, 2)); - let mut request = receive_prioritized_full_scan_request(&wallet); - - let indexes = scan_indexes(&mut request, KeychainKind::External, 7); - - assert_eq!(indexes, vec![1, 3, 4, 0, 2, 5, 6]); - } - - #[test] - fn receive_prioritized_scan_deduplicates_priority_indexes_from_normal_external_scan() { - let mut wallet = test_bdk_wallet(); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 4).last(); - assert!(wallet.mark_used(KeychainKind::External, 0)); - assert!(wallet.mark_used(KeychainKind::External, 2)); - let mut request = receive_prioritized_full_scan_request(&wallet); - - let indexes = scan_indexes(&mut request, KeychainKind::External, 10); - let unique_indexes = indexes.iter().copied().collect::>(); - - assert_eq!(indexes.len(), unique_indexes.len()); - } - - #[test] - fn receive_prioritized_scan_prefix_is_capped_at_gap_limit() { - let mut wallet = test_bdk_wallet(); - let gap_limit = u32::from(GAP_LIMIT); - let _ = wallet.reveal_addresses_to(KeychainKind::External, gap_limit + 2).last(); - let mut request = receive_prioritized_full_scan_request(&wallet); - - let indexes = scan_indexes(&mut request, KeychainKind::External, GAP_LIMIT as usize + 2); - let expected_prefix = (0..gap_limit).collect::>(); - - assert_eq!(&indexes[..GAP_LIMIT as usize], expected_prefix.as_slice()); - assert_eq!(indexes[GAP_LIMIT as usize], gap_limit); - } - - #[test] - fn receive_prioritized_scan_prefix_does_not_fill_with_unrevealed_external_indexes() { - let mut wallet = test_bdk_wallet(); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 2).last(); - assert!(wallet.mark_used(KeychainKind::External, 0)); - let mut request = receive_prioritized_full_scan_request(&wallet); - - let indexes = scan_indexes(&mut request, KeychainKind::External, 4); - - assert_eq!(indexes, vec![1, 2, 0, 3]); - } - - #[test] - fn receive_prioritized_scan_keeps_internal_keychain_after_external_keychain() { - let wallet = test_bdk_wallet(); - let request = receive_prioritized_full_scan_request(&wallet); - - assert_eq!(request.keychains(), vec![KeychainKind::External, KeychainKind::Internal]); - } - - #[test] - fn receive_prioritized_scan_construction_does_not_reveal_or_mark_addresses_used() { - let mut wallet = test_bdk_wallet(); - let _ = wallet.reveal_addresses_to(KeychainKind::External, 2).last(); - assert!(wallet.mark_used(KeychainKind::External, 0)); - let last_revealed_before = wallet.spk_index().last_revealed_indices(); - let unused_before = wallet - .list_unused_addresses(KeychainKind::External) - .map(|address| address.index) - .collect::>(); - - let _request = receive_prioritized_full_scan_request(&wallet); - - let last_revealed_after = wallet.spk_index().last_revealed_indices(); - let unused_after = wallet - .list_unused_addresses(KeychainKind::External) - .map(|address| address.index) - .collect::>(); - - assert_eq!(last_revealed_after, last_revealed_before); - assert_eq!(unused_after, unused_before); - } - - #[test] - fn address_type_switch_metadata_preserves_current_fields_and_resets_scan_fields() { - let mut current_metadata = WalletMetadata::preview_new(); - current_metadata.name = "renamed while discovering".to_string(); - current_metadata.selected_unit = crate::transaction::Unit::Sat; - current_metadata.sensitive_visible = false; - current_metadata.details_expanded = true; - current_metadata.show_labels = false; - current_metadata.internal.address_index = - Some(cove_types::AddressIndex { last_seen_index: 4, address_list_hash: 2 }); - current_metadata.internal.last_scan_finished = Some(std::time::Duration::from_secs(10)); - current_metadata.internal.last_height_fetched = Some(cove_types::BlockSizeLast { - block_height: 1, - last_seen: std::time::Duration::from_secs(20), - }); - current_metadata.internal.performed_full_scan_at = Some(30); - current_metadata.internal.store_type = metadata::StoreType::FileStore; - - let mut stale_actor_metadata = current_metadata.clone(); - stale_actor_metadata.name = "stale actor name".to_string(); - stale_actor_metadata.selected_unit = crate::transaction::Unit::Btc; - stale_actor_metadata.sensitive_visible = true; - stale_actor_metadata.details_expanded = false; - stale_actor_metadata.show_labels = true; - - let updated = - metadata_for_address_type_switch(current_metadata.clone(), WalletAddressType::Legacy); - - assert_eq!(updated.name, current_metadata.name); - assert_eq!(updated.selected_unit, current_metadata.selected_unit); - assert_eq!(updated.sensitive_visible, current_metadata.sensitive_visible); - assert_eq!(updated.details_expanded, current_metadata.details_expanded); - assert_eq!(updated.show_labels, current_metadata.show_labels); - assert_ne!(updated.name, stale_actor_metadata.name); - assert_ne!(updated.selected_unit, stale_actor_metadata.selected_unit); - assert_ne!(updated.sensitive_visible, stale_actor_metadata.sensitive_visible); - assert_ne!(updated.details_expanded, stale_actor_metadata.details_expanded); - assert_ne!(updated.show_labels, stale_actor_metadata.show_labels); - assert_eq!(updated.address_type, WalletAddressType::Legacy); - assert_eq!(updated.discovery_state, DiscoveryState::ChoseAdressType); - assert_eq!(updated.internal.address_index, None); - assert_eq!(updated.internal.last_scan_finished, None); - assert_eq!(updated.internal.last_height_fetched, None); - assert_eq!(updated.internal.performed_full_scan_at, None); - assert_eq!(updated.internal.store_type, metadata::StoreType::FileStore); - } - - #[test] - fn mnemonic_address_type_switch_metadata_keeps_new_derived_origin() { - let mut current_metadata = WalletMetadata::preview_new(); - current_metadata.name = "current database name".to_string(); - current_metadata.origin = Some("wpkh([73c5da0a/84'/0'/0'])".to_string()); - current_metadata.internal.last_scan_finished = Some(std::time::Duration::from_secs(10)); - - let mut derived_metadata = current_metadata.clone(); - derived_metadata.name = - "derived metadata should not replace current database name".to_string(); - derived_metadata.origin = Some("pkh([73c5da0a/44'/0'/0'])".to_string()); - - let updated = metadata_for_mnemonic_address_type_switch( - current_metadata.clone(), - &derived_metadata, - WalletAddressType::Legacy, - ); - - assert_eq!(updated.name, current_metadata.name); - assert_ne!(updated.name, derived_metadata.name); - assert_eq!(updated.origin, derived_metadata.origin); - assert_ne!(updated.origin, current_metadata.origin); - assert_eq!(updated.address_type, WalletAddressType::Legacy); - assert_eq!(updated.discovery_state, DiscoveryState::ChoseAdressType); - assert_eq!(updated.internal.last_scan_finished, None); - } - - #[test] - fn test_fingerprint() { - let _guard = crate::test_support::global_state_test_lock().blocking_lock(); - crate::database::test_support::delete_database(); - - let mnemonic = Mnemonic::parse_normalized( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); - - let metadata = WalletMetadata::preview_new(); - - let wallet = - Wallet::try_new_persisted_from_mnemonic_segwit(metadata.clone(), mnemonic, None) - .unwrap(); - - let fingerprint = wallet.metadata.master_fingerprint.as_ref().unwrap().as_lowercase(); - - let _ = delete_wallet_specific_data(&metadata.id); - assert_eq!("73c5da0a", fingerprint.as_str()); - } -} - #[uniffi::export] impl Wallet { #[uniffi::constructor] diff --git a/rust/src/wallet/addressing.rs b/rust/src/wallet/addressing.rs new file mode 100644 index 000000000..bd2503dc5 --- /dev/null +++ b/rust/src/wallet/addressing.rs @@ -0,0 +1,518 @@ +use bdk_wallet::KeychainKind; +use bdk_wallet::chain::spk_client::FullScanRequest; +use cove_bdk::descriptor_ext::DescriptorExt as _; +use cove_common::consts::GAP_LIMIT; +use cove_types::address::AddressInfoWithDerivation; +use cove_util::result_ext::ResultExt as _; +use tracing::debug; + +use crate::{ + bdk_store::BdkStore, + database::Database, + keychain::Keychain, + keys::Descriptors, + wallet::metadata::{DiscoveryState, WalletMetadata}, +}; + +use super::{AddressInfo, Wallet, WalletAddressType, WalletError}; + +impl Wallet { + pub(crate) fn start_receive_prioritized_full_scan(&self) -> FullScanRequest { + receive_prioritized_full_scan_request(&self.bdk) + } + + /// The user imported a hww and wants to switch from native segwit to a different address type + pub fn switch_descriptor_to_new_address_type( + &mut self, + descriptors: pubport::descriptor::Descriptors, + address_type: WalletAddressType, + ) -> Result<(), WalletError> { + debug!("switching public descriptor wallet to new address type"); + + let id = self.id.clone(); + + // delete the bdk wallet filestore + BdkStore::delete_sqlite_store(&self.id).map_err(|error| { + WalletError::PersistError(format!("failed to delete wallet filestore: {error}")) + })?; + + let store = BdkStore::try_new(&id, self.network); + let mut db = store.map_err_str(WalletError::LoadError)?.conn; + + let descriptors: Descriptors = descriptors.into(); + let wallet = descriptors + .into_create_params() + .network(self.network.into()) + .create_wallet(&mut db) + .map_err_str(WalletError::BdkError)?; + + // switch db and wallet + self.bdk = wallet; + self.db = parking_lot::Mutex::new(db); + let metadata = self.current_database_metadata()?; + let metadata = metadata_for_address_type_switch(metadata, address_type); + self.persist_address_type_switch_metadata(metadata)?; + + Ok(()) + } + + /// The user imported a hot wallet and wants to switch from native segwit to a different address type + pub fn switch_mnemonic_to_new_address_type( + &mut self, + address_type: WalletAddressType, + ) -> Result<(), WalletError> { + debug!("switching mnemonic wallet to new address type"); + + // delete the bdk wallet filestore + BdkStore::delete_sqlite_store(&self.id).map_err(|error| { + WalletError::PersistError(format!("failed to delete wallet filestore: {error}")) + })?; + + let mnemonic = Keychain::global() + .get_wallet_key(&self.id) + .ok() + .flatten() + .ok_or(WalletError::WalletNotFound)?; + + let metadata_for_new_wallet = self.current_database_metadata()?; + let mut me = Self::try_new_persisted_from_mnemonic( + metadata_for_new_wallet, + mnemonic, + None, + address_type, + )?; + let current_metadata = self.current_database_metadata()?; + let metadata = + metadata_for_mnemonic_address_type_switch(current_metadata, &me.metadata, address_type); + + // swap the wallet to the new one + std::mem::swap(&mut me, self); + self.persist_address_type_switch_metadata(metadata)?; + + Ok(()) + } + + pub fn get_next_address(&mut self) -> Result { + const MAX_ADDRESSES: usize = (GAP_LIMIT - 5) as usize; + + let addresses: Vec = self + .bdk + .list_unused_addresses(KeychainKind::External) + .take(MAX_ADDRESSES) + .map(Into::into) + .collect(); + + // get up to 25 revealed but unused addresses + if addresses.len() < MAX_ADDRESSES { + let address_info = + AddressInfo::from(self.bdk.reveal_next_address(KeychainKind::External)); + + self.persist()?; + + let derivation_path = + self.bdk.public_descriptor(KeychainKind::External).derivation_path().ok(); + let info = AddressInfoWithDerivation::new(address_info, derivation_path); + return Ok(info); + } + + // if we have already revealed 25 addresses, we cycle back to the first one + // and present those addresses, until a next unused address is available, if we don't + // do this we could hit the gap limit and users might use a an adddress past + // the gap limit and not be able to see it their wallet + // + // note: index to use is the index of the address in the list of addresses, not the derivation index + let index_to_use = + if let Some(last_index) = self.metadata.internal.last_seen_address_index(&addresses) { + (last_index + 1) % MAX_ADDRESSES + } else { + 0 + }; + + let address_info = addresses[index_to_use].clone(); + self.metadata.internal.set_last_seen_address_index(&addresses, index_to_use); + + Database::global().wallets.update_internal_metadata(&self.metadata)?; + + let public_descriptor = self.bdk.public_descriptor(KeychainKind::External); + let derivation_path = public_descriptor.derivation_path().ok(); + let address_info_with_derivation = + AddressInfoWithDerivation::new(address_info, derivation_path); + + Ok(address_info_with_derivation) + } + + pub fn receive_address_at_index(&self, index: u32) -> AddressInfoWithDerivation { + let address_info = AddressInfo::from(self.bdk.peek_address(KeychainKind::External, index)); + let public_descriptor = self.bdk.public_descriptor(KeychainKind::External); + let derivation_path = public_descriptor.derivation_path().ok(); + + AddressInfoWithDerivation::new(address_info, derivation_path) + } + + pub fn receive_address_is_unused(&self, index: u32) -> bool { + self.bdk.list_unused_addresses(KeychainKind::External).any(|address| address.index == index) + } + + pub fn mark_receive_address_used(&mut self, index: u32) -> Result<(), WalletError> { + if self.bdk.mark_used(KeychainKind::External, index) { + self.persist()?; + } + + Ok(()) + } + + pub fn unreserve_tx_change_addresses(&mut self, tx: &bdk_wallet::bitcoin::Transaction) { + for txout in &tx.output { + if let Some((KeychainKind::Internal, index)) = + self.bdk.derivation_of_spk(txout.script_pubkey.clone()) + { + self.bdk.unmark_used(KeychainKind::Internal, index); + } + } + } +} + +/// Builds an incremental scan request that checks revealed-unused receive addresses first +/// +/// The request still uses unbounded BDK SPK iterators. The progressive scanner owns stop-gap +/// enforcement, so the normal external iterator resumes from index `0` with prioritized indexes +/// filtered out instead of being capped to the gap limit +fn receive_prioritized_full_scan_request( + wallet: &bdk_wallet::Wallet, +) -> FullScanRequest { + let mut builder = FullScanRequest::builder().chain_tip(wallet.local_chain().tip()); + + let priority_spks = wallet + .list_unused_addresses(KeychainKind::External) + .take(GAP_LIMIT as usize) + .map(|address| (address.index, address.address.script_pubkey())) + .collect::>(); + + let priority_indices = priority_spks.iter().map(|(index, _)| *index).collect::>(); + + if let Some(external_spks) = wallet.spk_index().unbounded_spk_iter(KeychainKind::External) { + let external_spks = priority_spks + .into_iter() + .chain(external_spks.filter(move |(index, _)| !priority_indices.contains(index))); + + builder = builder.spks_for_keychain(KeychainKind::External, external_spks); + } + + if let Some(internal_spks) = wallet.spk_index().unbounded_spk_iter(KeychainKind::Internal) { + builder = builder.spks_for_keychain(KeychainKind::Internal, internal_spks); + } + + builder.build() +} + +fn metadata_for_address_type_switch( + mut metadata: WalletMetadata, + address_type: WalletAddressType, +) -> WalletMetadata { + metadata.address_type = address_type; + metadata.discovery_state = DiscoveryState::ChoseAdressType; + metadata.internal.reset_scan_state_for_address_type_switch(); + metadata +} + +fn metadata_for_mnemonic_address_type_switch( + current_metadata: WalletMetadata, + derived_metadata: &WalletMetadata, + address_type: WalletAddressType, +) -> WalletMetadata { + let mut metadata = metadata_for_address_type_switch(current_metadata, address_type); + metadata.master_fingerprint = derived_metadata.master_fingerprint.clone(); + metadata.origin = derived_metadata.origin.clone(); + metadata +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use bdk_wallet::bitcoin::{ + Address as BdkAddress, Amount, BlockHash, Network, hashes::Hash as _, + }; + use bdk_wallet::chain::{BlockId, ConfirmationBlockTime}; + use bdk_wallet::test_utils::{ + get_funded_wallet_wpkh, get_test_wpkh_and_change_desc, insert_anchor, insert_checkpoint, + insert_tx, + }; + + use super::*; + use crate::wallet::metadata::{StoreType, WalletMetadata}; + + fn test_bdk_wallet() -> bdk_wallet::Wallet { + let (external_descriptor, internal_descriptor) = get_test_wpkh_and_change_desc(); + + bdk_wallet::Wallet::create(external_descriptor, internal_descriptor) + .network(Network::Regtest) + .create_wallet_no_persist() + .expect("wallet is created") + } + + fn scan_indexes( + request: &mut FullScanRequest, + keychain: KeychainKind, + count: usize, + ) -> Vec { + request.iter_spks(keychain).take(count).map(|(index, _)| index).collect() + } + + fn build_tx_with_change(wallet: &mut bdk_wallet::Wallet) -> bdk_wallet::bitcoin::Psbt { + let address = BdkAddress::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + + let mut builder = wallet.build_tx(); + builder.add_recipient(address.script_pubkey(), Amount::from_sat(10_000)); + builder.fee_absolute(Amount::from_sat(1_000)); + builder.finish().unwrap() + } + + fn tx_output_index( + wallet: &bdk_wallet::Wallet, + tx: &bdk_wallet::bitcoin::Transaction, + keychain: KeychainKind, + ) -> u32 { + tx.output + .iter() + .find_map(|txout| match wallet.derivation_of_spk(txout.script_pubkey.clone()) { + Some((txout_keychain, index)) if txout_keychain == keychain => Some(index), + _ => None, + }) + .unwrap() + } + + fn unused_addresses_contain( + wallet: &bdk_wallet::Wallet, + keychain: KeychainKind, + index: u32, + ) -> bool { + wallet.list_unused_addresses(keychain).any(|address| address.index == index) + } + + fn unreserve_tx_change_addresses( + wallet: &mut bdk_wallet::Wallet, + tx: &bdk_wallet::bitcoin::Transaction, + ) { + for txout in &tx.output { + if let Some((KeychainKind::Internal, index)) = + wallet.derivation_of_spk(txout.script_pubkey.clone()) + { + wallet.unmark_used(KeychainKind::Internal, index); + } + } + } + + #[test] + fn unreserve_tx_change_addresses_releases_reserved_change_index() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let psbt = build_tx_with_change(&mut wallet); + let change_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::Internal); + + assert!(!unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); + + unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); + + assert!(unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); + } + + #[test] + fn unreserve_tx_change_addresses_keeps_confirmed_change_index_used() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let psbt = build_tx_with_change(&mut wallet); + let change_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::Internal); + let block_id = BlockId { height: 1, hash: BlockHash::hash(b"confirmed change") }; + let confirmation = ConfirmationBlockTime { block_id, confirmation_time: 1 }; + + insert_checkpoint(&mut wallet, block_id); + insert_tx(&mut wallet, psbt.unsigned_tx.clone()); + insert_anchor(&mut wallet, psbt.unsigned_tx.compute_txid(), confirmation); + + unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); + + assert!(!unused_addresses_contain(&wallet, KeychainKind::Internal, change_index)); + } + + #[test] + fn unreserve_tx_change_addresses_keeps_self_send_receive_index_used() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let receive_address = wallet.reveal_next_address(KeychainKind::External); + + assert!(wallet.mark_used(KeychainKind::External, receive_address.index)); + + let mut builder = wallet.build_tx(); + builder.add_recipient(receive_address.address.script_pubkey(), Amount::from_sat(10_000)); + builder.fee_absolute(Amount::from_sat(1_000)); + + let psbt = builder.finish().unwrap(); + let receive_index = tx_output_index(&wallet, &psbt.unsigned_tx, KeychainKind::External); + + assert_eq!(receive_address.index, receive_index); + assert!(!unused_addresses_contain(&wallet, KeychainKind::External, receive_index)); + + unreserve_tx_change_addresses(&mut wallet, &psbt.unsigned_tx); + + assert!(!unused_addresses_contain(&wallet, KeychainKind::External, receive_index)); + } + + #[test] + fn receive_prioritized_scan_checks_revealed_unused_external_indexes_first() { + let mut wallet = test_bdk_wallet(); + let _ = wallet.reveal_addresses_to(KeychainKind::External, 4).last(); + assert!(wallet.mark_used(KeychainKind::External, 0)); + assert!(wallet.mark_used(KeychainKind::External, 2)); + let mut request = receive_prioritized_full_scan_request(&wallet); + + let indexes = scan_indexes(&mut request, KeychainKind::External, 7); + + assert_eq!(indexes, vec![1, 3, 4, 0, 2, 5, 6]); + } + + #[test] + fn receive_prioritized_scan_deduplicates_priority_indexes_from_normal_external_scan() { + let mut wallet = test_bdk_wallet(); + let _ = wallet.reveal_addresses_to(KeychainKind::External, 4).last(); + assert!(wallet.mark_used(KeychainKind::External, 0)); + assert!(wallet.mark_used(KeychainKind::External, 2)); + let mut request = receive_prioritized_full_scan_request(&wallet); + + let indexes = scan_indexes(&mut request, KeychainKind::External, 10); + let unique_indexes = indexes.iter().copied().collect::>(); + + assert_eq!(indexes.len(), unique_indexes.len()); + } + + #[test] + fn receive_prioritized_scan_prefix_is_capped_at_gap_limit() { + let mut wallet = test_bdk_wallet(); + let gap_limit = u32::from(GAP_LIMIT); + let _ = wallet.reveal_addresses_to(KeychainKind::External, gap_limit + 2).last(); + let mut request = receive_prioritized_full_scan_request(&wallet); + + let indexes = scan_indexes(&mut request, KeychainKind::External, GAP_LIMIT as usize + 2); + let expected_prefix = (0..gap_limit).collect::>(); + + assert_eq!(&indexes[..GAP_LIMIT as usize], expected_prefix.as_slice()); + assert_eq!(indexes[GAP_LIMIT as usize], gap_limit); + } + + #[test] + fn receive_prioritized_scan_prefix_does_not_fill_with_unrevealed_external_indexes() { + let mut wallet = test_bdk_wallet(); + let _ = wallet.reveal_addresses_to(KeychainKind::External, 2).last(); + assert!(wallet.mark_used(KeychainKind::External, 0)); + let mut request = receive_prioritized_full_scan_request(&wallet); + + let indexes = scan_indexes(&mut request, KeychainKind::External, 4); + + assert_eq!(indexes, vec![1, 2, 0, 3]); + } + + #[test] + fn receive_prioritized_scan_keeps_internal_keychain_after_external_keychain() { + let wallet = test_bdk_wallet(); + let request = receive_prioritized_full_scan_request(&wallet); + + assert_eq!(request.keychains(), vec![KeychainKind::External, KeychainKind::Internal]); + } + + #[test] + fn receive_prioritized_scan_construction_does_not_reveal_or_mark_addresses_used() { + let mut wallet = test_bdk_wallet(); + let _ = wallet.reveal_addresses_to(KeychainKind::External, 2).last(); + assert!(wallet.mark_used(KeychainKind::External, 0)); + let last_revealed_before = wallet.spk_index().last_revealed_indices(); + let unused_before = wallet + .list_unused_addresses(KeychainKind::External) + .map(|address| address.index) + .collect::>(); + + let _request = receive_prioritized_full_scan_request(&wallet); + + let last_revealed_after = wallet.spk_index().last_revealed_indices(); + let unused_after = wallet + .list_unused_addresses(KeychainKind::External) + .map(|address| address.index) + .collect::>(); + + assert_eq!(last_revealed_after, last_revealed_before); + assert_eq!(unused_after, unused_before); + } + + #[test] + fn address_type_switch_metadata_preserves_current_fields_and_resets_scan_fields() { + let mut current_metadata = WalletMetadata::preview_new(); + current_metadata.name = "renamed while discovering".to_string(); + current_metadata.selected_unit = crate::transaction::Unit::Sat; + current_metadata.sensitive_visible = false; + current_metadata.details_expanded = true; + current_metadata.show_labels = false; + current_metadata.internal.address_index = + Some(cove_types::AddressIndex { last_seen_index: 4, address_list_hash: 2 }); + current_metadata.internal.last_scan_finished = Some(std::time::Duration::from_secs(10)); + current_metadata.internal.last_height_fetched = Some(cove_types::BlockSizeLast { + block_height: 1, + last_seen: std::time::Duration::from_secs(20), + }); + current_metadata.internal.performed_full_scan_at = Some(30); + current_metadata.internal.store_type = StoreType::FileStore; + + let mut stale_actor_metadata = current_metadata.clone(); + stale_actor_metadata.name = "stale actor name".to_string(); + stale_actor_metadata.selected_unit = crate::transaction::Unit::Btc; + stale_actor_metadata.sensitive_visible = true; + stale_actor_metadata.details_expanded = false; + stale_actor_metadata.show_labels = true; + + let updated = + metadata_for_address_type_switch(current_metadata.clone(), WalletAddressType::Legacy); + + assert_eq!(updated.name, current_metadata.name); + assert_eq!(updated.selected_unit, current_metadata.selected_unit); + assert_eq!(updated.sensitive_visible, current_metadata.sensitive_visible); + assert_eq!(updated.details_expanded, current_metadata.details_expanded); + assert_eq!(updated.show_labels, current_metadata.show_labels); + assert_ne!(updated.name, stale_actor_metadata.name); + assert_ne!(updated.selected_unit, stale_actor_metadata.selected_unit); + assert_ne!(updated.sensitive_visible, stale_actor_metadata.sensitive_visible); + assert_ne!(updated.details_expanded, stale_actor_metadata.details_expanded); + assert_ne!(updated.show_labels, stale_actor_metadata.show_labels); + assert_eq!(updated.address_type, WalletAddressType::Legacy); + assert_eq!(updated.discovery_state, DiscoveryState::ChoseAdressType); + assert_eq!(updated.internal.address_index, None); + assert_eq!(updated.internal.last_scan_finished, None); + assert_eq!(updated.internal.last_height_fetched, None); + assert_eq!(updated.internal.performed_full_scan_at, None); + assert_eq!(updated.internal.store_type, StoreType::FileStore); + } + + #[test] + fn mnemonic_address_type_switch_metadata_keeps_new_derived_origin() { + let mut current_metadata = WalletMetadata::preview_new(); + current_metadata.name = "current database name".to_string(); + current_metadata.origin = Some("wpkh([73c5da0a/84'/0'/0'])".to_string()); + current_metadata.internal.last_scan_finished = Some(std::time::Duration::from_secs(10)); + + let mut derived_metadata = current_metadata.clone(); + derived_metadata.name = + "derived metadata should not replace current database name".to_string(); + derived_metadata.origin = Some("pkh([73c5da0a/44'/0'/0'])".to_string()); + + let updated = metadata_for_mnemonic_address_type_switch( + current_metadata.clone(), + &derived_metadata, + WalletAddressType::Legacy, + ); + + assert_eq!(updated.name, current_metadata.name); + assert_ne!(updated.name, derived_metadata.name); + assert_eq!(updated.origin, derived_metadata.origin); + assert_ne!(updated.origin, current_metadata.origin); + assert_eq!(updated.address_type, WalletAddressType::Legacy); + assert_eq!(updated.discovery_state, DiscoveryState::ChoseAdressType); + assert_eq!(updated.internal.last_scan_finished, None); + } +} diff --git a/rust/src/wallet/builder.rs b/rust/src/wallet/builder.rs new file mode 100644 index 000000000..38676d75e --- /dev/null +++ b/rust/src/wallet/builder.rs @@ -0,0 +1,825 @@ +use std::sync::Arc; + +use bdk_wallet::KeychainKind; +use bdk_wallet::bitcoin::bip32::Xpub; +use bdk_wallet::miniscript::descriptor::ShInner; +use bip39::Mnemonic; +use cove_types::Network; +use cove_util::result_ext::ResultExt as _; +use parking_lot::Mutex; +use pubport::formats::Format; +use tracing::{error, warn}; + +use crate::{ + app::reconcile::{Update, Updater}, + bdk_store::BdkStore, + database::Database, + keychain::Keychain, + keys::{Descriptor, Descriptors}, + manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER, + mnemonic::MnemonicExt as _, + tap_card::tap_signer_reader::DeriveInfo, + wallet_identity::{PublicWalletIdentity, existing_public_wallet_by_identity_strict}, + xpub::{self, XpubError}, +}; + +use super::{ + Wallet, WalletAddressType, WalletError, delete_wallet_specific_data, + fingerprint::Fingerprint, + metadata, + metadata::{ + DiscoveryState, HardwareWalletMetadata, WalletBirthday, WalletId, WalletMetadata, + WalletType, tap_signer_import_birthday, + }, +}; + +pub(crate) struct WalletBuilder { + source: WalletSource, +} + +pub(crate) enum WalletSource { + PersistedAndSelected { + metadata: WalletMetadata, + mnemonic: Mnemonic, + passphrase: Option, + }, + Xpub(String), + Pubport(Box), + TapSigner { + tap_signer: Arc, + derive: DeriveInfo, + backup: Option>, + birthday: Option, + }, + Mnemonic { + metadata: WalletMetadata, + mnemonic: Mnemonic, + passphrase: Option, + address_type: WalletAddressType, + }, +} + +impl WalletBuilder { + pub(crate) fn new(source: WalletSource) -> Self { + Self { source } + } + + pub(crate) fn build(self) -> Result { + match self.source { + WalletSource::PersistedAndSelected { metadata, mnemonic, passphrase } => { + Self::build_persisted_and_selected(metadata, mnemonic, passphrase) + } + WalletSource::Xpub(xpub) => Self::build_from_xpub(xpub), + WalletSource::Pubport(pubport) => Self::build_from_pubport(*pubport), + WalletSource::TapSigner { tap_signer, derive, backup, birthday } => { + Self::build_from_tap_signer(tap_signer, derive, backup, birthday) + } + WalletSource::Mnemonic { metadata, mnemonic, passphrase, address_type } => { + Self::build_from_mnemonic(metadata, mnemonic, passphrase, address_type) + } + } + } + + fn build_persisted_and_selected( + metadata: WalletMetadata, + mnemonic: Mnemonic, + passphrase: Option, + ) -> Result { + let keychain = Keychain::global(); + let database = Database::global(); + + let create_wallet = || -> Result { + // create bdk wallet filestore, set id to metadata id + let me = Self::build_from_mnemonic( + metadata.clone(), + mnemonic.clone(), + passphrase, + WalletAddressType::NativeSegwit, + )?; + + // save mnemonic for private key + keychain.save_wallet_key(&me.id, mnemonic.clone())?; + + // save public key in keychain too + let xpub = mnemonic.xpub(me.network.into()); + keychain.save_wallet_xpub(&me.id, xpub)?; + + let (external_descriptor, internal_descriptor) = { + let external_descriptor = me.bdk.public_descriptor(KeychainKind::External); + let internal_descriptor = me.bdk.public_descriptor(KeychainKind::Internal); + + (external_descriptor.clone(), internal_descriptor.clone()) + }; + + // save public descriptors in keychain too + keychain.save_public_descriptor(&me.id, external_descriptor, internal_descriptor)?; + + // save wallet_metadata to database + database.wallets.save_new_wallet_metadata(me.metadata.clone())?; + + // set this wallet as the selected wallet + database.global_config.select_wallet(me.id.clone())?; + + Ok(me) + }; + + // clean up if we fail to create the wallet + let me = match create_wallet() { + Ok(me) => me, + Err(error) => { + error!("failed to create wallet: {error}"); + + // delete the secret key, xpub and public descriptor from the keychain + keychain.delete_wallet_items(&metadata.id); + + if let Err(error) = delete_wallet_specific_data(&metadata.id) { + warn!("clean up failed, failed to delete wallet data: {error}"); + } + + if let Err(error) = database.wallets.delete(&metadata.id) { + warn!("clean up failed, failed to delete wallet: {error}"); + } + + if let Err(error) = database.global_config.clear_selected_wallet() { + warn!("clean up failed, failed to clear selected wallet: {error}"); + } + + return Err(error); + } + }; + + Ok(me) + } + + fn build_from_xpub(xpub: String) -> Result { + let xpub = xpub.trim(); + let hardware_export = pubport::Format::try_new_from_str(xpub) + .map_err(Into::into) + .map_err(WalletError::ParseXpubError); + + if let Ok(hardware_export) = hardware_export { + return Self::new(WalletSource::Pubport(Box::new(hardware_export))).build(); + } + + // already returned if its a valid xpub + Err(hardware_export.unwrap_err()) + } + + fn build_from_pubport(pubport: pubport::Format) -> Result { + let keychain = Keychain::global(); + let database = Database::global(); + let network = database.global_config.selected_network(); + let mode = database.global_config.wallet_mode(); + + let id = WalletId::new(); + let mut metadata = WalletMetadata::new_for_hardware(id.clone(), "", None); + + let pubport_descriptors = match pubport { + Format::Descriptor(descriptors) => descriptors, + Format::Json(json) => { + let (descriptors, address_type) = preferred_json_descriptors(&json)?; + + if should_start_json_discovery(&json, address_type) { + metadata.discovery_state = + DiscoveryState::StartedJson(Arc::new((*json).into())); + } + + descriptors + } + Format::Wasabi(descriptors) => descriptors, + Format::Electrum(descriptors) => descriptors, + Format::KeyExpression(descriptors) => descriptors, + }; + + // compute xpub and descriptors early so they're available for the upgrade path + let descriptors: Descriptors = pubport_descriptors.into(); + metadata.address_type = address_type_from_descriptors(&descriptors)?; + let fingerprint = descriptors.fingerprint(); + let xpub = xpub_from_descriptors(&descriptors)?; + + let incoming_identity = PublicWalletIdentity::from_descriptors(&descriptors); + + // check for existing wallet with same public identity, upgrade watch-only to cold + if let Some(fingerprint) = fingerprint.as_ref() { + let fingerprint: Fingerprint = (*fingerprint).into(); + metadata.master_fingerprint = Some(fingerprint.into()); + + let existing = existing_public_wallet_by_identity_strict( + &database, + keychain, + network, + mode, + fingerprint, + &incoming_identity, + ) + .map_err_str(WalletError::LoadError)?; + + if let Some(existing_metadata) = existing { + return Self::upgrade_to_cold( + existing_metadata, + &metadata, + xpub, + descriptors, + keychain, + &database, + ); + } + } + + let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; + + let fingerprint = fingerprint.map(|s| s.to_string()); + + metadata.name = match &fingerprint { + Some(fingerprint) => format!("Imported {}", fingerprint.to_ascii_uppercase()), + None => "Imported XPub".to_string(), + }; + + metadata.wallet_type = match &fingerprint { + Some(_) => WalletType::Cold, + None => WalletType::XpubOnly, + }; + + // get origin only if its not a watch only wallet + match metadata.wallet_type { + WalletType::Hot | WalletType::Cold => { + metadata.origin = descriptors.origin().ok(); + } + _ => {} + } + + let wallet = descriptors + .clone() + .into_create_params() + .network(network.into()) + .create_wallet(&mut store.conn) + .map_err_str(WalletError::BdkError)?; + + // save public key in keychain too + keychain.save_wallet_xpub(&id, xpub)?; + + // save public descriptor in keychain too + keychain.save_public_descriptor( + &metadata.id, + descriptors.external.extended_descriptor, + descriptors.internal.extended_descriptor, + )?; + + database.wallets.save_new_wallet_metadata(metadata.clone())?; + CLOUD_BACKUP_MANAGER.handle_wallet_set_change(); + + Ok(Wallet { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) + } + + fn build_from_tap_signer( + tap_signer: Arc, + derive: DeriveInfo, + backup: Option>, + birthday: Option, + ) -> Result { + let keychain = Keychain::global(); + let database = Database::global(); + let mode = database.global_config.wallet_mode(); + let network = database.global_config.selected_network(); + assert!(network == derive.network); + + let id = WalletId::new(); + + let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; + + let descriptors = Descriptors::new_from_tap_signer(&derive) + .map_err_str(WalletError::DescriptorKeyParseError)?; + + let fingerprint = Fingerprint::from(derive.master_fingerprint()); + + // set metadata + let mut metadata = WalletMetadata::new_for_hardware(id.clone(), "", None); + metadata.name = "TAPSIGNER".to_string(); + metadata.wallet_mode = mode; + metadata.hardware_metadata = Some(HardwareWalletMetadata::TapSigner(tap_signer)); + metadata.origin = descriptors.origin().ok(); + metadata.master_fingerprint = Some(Arc::new(fingerprint)); + metadata.wallet_type = WalletType::Cold; + metadata.birthday = + birthday.or_else(|| tap_signer_import_birthday(network, derive.birth_height)); + + // make sure its not already imported + check_for_duplicate_wallet(network, mode, fingerprint)?; + + let xpub = + descriptors.external.xpub().expect("tap_signer descriptor always made with xpub"); + + let wallet = descriptors + .clone() + .into_create_params() + .network(network.into()) + .create_wallet(&mut store.conn) + .map_err_str(WalletError::BdkError)?; + + // save public key in keychain too + keychain.save_wallet_xpub(&id, xpub)?; + + // save public descriptor in keychain too + keychain.save_public_descriptor( + &metadata.id, + descriptors.external.extended_descriptor, + descriptors.internal.extended_descriptor, + )?; + + // if theres a backup for this wallet, save it in the keychain + if let Some(backup) = backup { + keychain.save_tap_signer_backup(&id, backup.as_slice())?; + } + + database.wallets.save_new_wallet_metadata(metadata.clone())?; + CLOUD_BACKUP_MANAGER.handle_wallet_set_change(); + + Ok(Wallet { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) + } + + fn build_from_mnemonic( + mut metadata: WalletMetadata, + mnemonic: Mnemonic, + passphrase: Option, + address_type: WalletAddressType, + ) -> Result { + let network = Database::global().global_config.selected_network(); + + let id = metadata.id.clone(); + let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?; + + let descriptors = mnemonic.into_descriptors(passphrase, network, address_type); + let origin = descriptors.origin().ok(); + + metadata.master_fingerprint = descriptors.fingerprint().map(|f| Arc::new(f.into())); + metadata.origin = origin; + + let wallet = descriptors + .into_create_params() + .network(network.into()) + .create_wallet(&mut store.conn) + .map_err_str(WalletError::BdkError)?; + + Ok(Wallet { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) }) + } + + fn upgrade_to_cold( + mut metadata: WalletMetadata, + import_metadata: &WalletMetadata, + xpub: Xpub, + descriptors: Descriptors, + keychain: &Keychain, + database: &Database, + ) -> Result { + if metadata.wallet_type != WalletType::WatchOnly { + return Err(WalletError::WalletAlreadyExists(metadata.id)); + } + + let id = metadata.id.clone(); + keychain.save_wallet_xpub(&id, xpub)?; + + metadata = metadata_for_cold_upgrade(metadata, import_metadata, &descriptors); + + keychain.save_public_descriptor( + &id, + descriptors.external.extended_descriptor, + descriptors.internal.extended_descriptor, + )?; + + database.wallets.update_wallet_metadata(metadata)?; + database.global_config.select_wallet(id.clone())?; + + Updater::send_update(Update::ClearCachedWalletManager(id.clone())); + CLOUD_BACKUP_MANAGER.handle_wallet_backup_change_and_reverify(id.clone()); + Wallet::try_load_persisted(id) + } +} + +fn check_for_duplicate_wallet( + network: Network, + mode: metadata::WalletMode, + fingerprint: Fingerprint, +) -> Result<(), WalletError> { + let all_fingerprints: Vec<(WalletId, Arc)> = Database::global() + .wallets + .get_all(network, mode) + .map(|wallets| { + wallets + .into_iter() + .filter_map(|wallet_metadata| { + let fingerprint = wallet_metadata.master_fingerprint?; + Some((wallet_metadata.id, fingerprint)) + }) + .collect() + }) + .unwrap_or_default(); + + if let Some((id, _)) = all_fingerprints.into_iter().find(|(_, f)| f.as_ref() == &fingerprint) { + return Err(WalletError::WalletAlreadyExists(id)); + } + + Ok(()) +} + +fn metadata_for_cold_upgrade( + mut existing_metadata: WalletMetadata, + import_metadata: &WalletMetadata, + descriptors: &Descriptors, +) -> WalletMetadata { + existing_metadata.wallet_type = WalletType::Cold; + existing_metadata.origin = descriptors.origin().ok(); + existing_metadata.address_type = import_metadata.address_type; + + if import_metadata.discovery_state != DiscoveryState::Single { + existing_metadata.discovery_state = import_metadata.discovery_state.clone(); + } + + existing_metadata +} + +/// Selects the JSON descriptor set that becomes the imported wallet +/// +/// A JSON export can carry several standard account types. Cove imports the most modern +/// supported primary wallet and lets discovery scan any supported older alternates separately +fn preferred_json_descriptors( + json: &pubport::formats::Json, +) -> Result<(pubport::descriptor::Descriptors, WalletAddressType), WalletError> { + [ + (&json.bip84, WalletAddressType::NativeSegwit), + (&json.bip49, WalletAddressType::WrappedSegwit), + (&json.bip44, WalletAddressType::Legacy), + ] + .into_iter() + .find_map(|(descriptors, address_type)| { + descriptors.as_ref().map(|descriptors| (descriptors.clone(), address_type)) + }) + .ok_or_else(|| { + WalletError::ParseXpubError(xpub::XpubError::MissingXpub( + "No supported BIP44, BIP49, or BIP84 xpub found".to_string(), + )) + }) +} + +fn xpub_from_descriptors(descriptors: &Descriptors) -> Result { + descriptors.external.xpub().ok_or(WalletError::ParseXpubError(XpubError::InvalidDescriptor( + xpub::DescriptorError::NoXpubInDescriptor, + ))) +} + +fn address_type_from_descriptors( + descriptors: &Descriptors, +) -> Result { + let external = address_type_from_descriptor(&descriptors.external)?; + let internal = address_type_from_descriptor(&descriptors.internal)?; + + if external != internal { + return Err(WalletError::UnsupportedWallet( + "external and internal descriptors use different address types".to_string(), + )); + } + + Ok(external) +} + +fn address_type_from_descriptor(descriptor: &Descriptor) -> Result { + match &descriptor.extended_descriptor { + bdk_wallet::miniscript::Descriptor::Pkh(_) => Ok(WalletAddressType::Legacy), + bdk_wallet::miniscript::Descriptor::Wpkh(_) => Ok(WalletAddressType::NativeSegwit), + bdk_wallet::miniscript::Descriptor::Sh(sh) => match sh.as_inner() { + ShInner::Wpkh(_) => Ok(WalletAddressType::WrappedSegwit), + _ => Err(unsupported_descriptor_address_type("non-wrapped-SegWit P2SH")), + }, + bdk_wallet::miniscript::Descriptor::Tr(_) => { + Err(unsupported_descriptor_address_type("Taproot")) + } + _ => Err(unsupported_descriptor_address_type("this descriptor type")), + } +} + +fn unsupported_descriptor_address_type(name: &str) -> WalletError { + WalletError::UnsupportedWallet(format!("{name} descriptors are not supported")) +} + +/// Returns whether a JSON import needs alternate address-type discovery +/// +/// Discovery only starts when the export has a supported alternate that differs from the +/// primary wallet, because the primary descriptor is synced by the main wallet +/// +/// Native SegWit (`bip84`) is intentionally absent because it is the preferred primary +/// descriptor when present, not an alternate discovery target +fn should_start_json_discovery( + json: &pubport::formats::Json, + address_type: WalletAddressType, +) -> bool { + [(&json.bip49, WalletAddressType::WrappedSegwit), (&json.bip44, WalletAddressType::Legacy)] + .into_iter() + .any(|(descriptors, type_)| descriptors.is_some() && type_ != address_type) +} + +#[cfg(test)] +mod tests { + use bip39::Mnemonic; + + use super::*; + + const BIP49_YPUB: &str = "ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"; + const BIP84_ZPUB: &str = "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"; + + fn pubport_descriptors(descriptor: &str) -> pubport::descriptor::Descriptors { + pubport::descriptor::Descriptors::try_from_line(descriptor) + .expect("descriptor fixture is valid") + } + + fn descriptor_json( + bip44: Option, + bip49: Option, + bip84: Option, + ) -> pubport::formats::Json { + pubport::formats::Json { bip44, bip49, bip84, bip86: None } + } + + fn json_from_export(export: &str) -> pubport::formats::Json { + let format = pubport::Format::try_new_from_str(export).expect("export fixture is valid"); + let pubport::Format::Json(json) = format else { + panic!("Expected JSON export"); + }; + + *json + } + + fn bip44_descriptors() -> pubport::descriptor::Descriptors { + pubport_descriptors( + "pkh([817e7be0/44h/0h/0h]xpub6BoKN14JzSFN1T3cqe9FnrwnXGAsmbgETJyeazoa3F7aMXh4XndvVrJAYyM127FsrH8KFv5XFXDroqXNfZMfsinow7xp93ueYSpnrjBBFs4/<0;1>/*)#tdtrl3y9", + ) + } + + fn bip49_descriptors() -> pubport::descriptor::Descriptors { + pubport_descriptors( + "sh(wpkh([817e7be0/49h/0h/0h]xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm/<0;1>/*))#8llmt36x", + ) + } + + fn bip84_descriptors() -> pubport::descriptor::Descriptors { + pubport_descriptors( + "wpkh([817e7be0/84h/0h/0h]xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM/<0;1>/*)#60tjs4c7", + ) + } + + fn bip86_descriptors() -> pubport::descriptor::Descriptors { + pubport_descriptors( + "tr([817e7be0/86h/0h/0h]xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM/<0;1>/*)", + ) + } + + fn assert_missing_supported_xpub(error: WalletError) { + let WalletError::ParseXpubError(xpub::XpubError::MissingXpub(message)) = error else { + panic!("expected missing xpub error"); + }; + + assert_eq!(message, "No supported BIP44, BIP49, or BIP84 xpub found"); + } + + #[test] + fn preferred_json_descriptors_uses_native_segwit_when_present() { + let json = descriptor_json( + Some(bip44_descriptors()), + Some(bip49_descriptors()), + Some(bip84_descriptors()), + ); + + let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::NativeSegwit); + assert!(descriptors.external.to_string().starts_with("wpkh(")); + } + + #[test] + fn preferred_json_descriptors_falls_back_to_wrapped_segwit() { + let json = descriptor_json(Some(bip44_descriptors()), Some(bip49_descriptors()), None); + + let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::WrappedSegwit); + assert!(descriptors.external.to_string().starts_with("sh(wpkh(")); + } + + #[test] + fn preferred_json_descriptors_falls_back_to_legacy() { + let json = descriptor_json(Some(bip44_descriptors()), None, None); + + let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::Legacy); + assert!(descriptors.external.to_string().starts_with("pkh(")); + } + + #[test] + fn preferred_json_descriptors_errors_without_supported_xpub() { + let json = descriptor_json(None, None, None); + let error = preferred_json_descriptors(&json).unwrap_err(); + + assert_missing_supported_xpub(error); + } + + #[test] + fn preferred_json_descriptors_rejects_bip86_only_export() { + let json = json_from_export( + r#"{ + "xfp": "817e7be0", + "bip86": { + "deriv": "m/86'/0'/0'", + "xpub": "xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM", + "first": "bc1p5cyxnuxmeuwuvkwfem96l9z7k3d5en0fhzc3wkvsgq4wv5q3xpqsv0gz6u" + } +}"#, + ); + let error = preferred_json_descriptors(&json).unwrap_err(); + + assert_missing_supported_xpub(error); + } + + #[test] + fn bare_ypub_import_selects_wrapped_segwit_descriptor() { + let json = json_from_export(BIP49_YPUB); + + assert!(json.bip49.is_some()); + assert!(json.bip84.is_none()); + + let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::WrappedSegwit); + assert!(descriptors.external.to_string().starts_with("sh(wpkh(")); + assert!(!should_start_json_discovery(&json, address_type)); + } + + #[test] + fn wrapped_segwit_json_import_material_exposes_nested_xpub() { + let json = descriptor_json(None, Some(bip49_descriptors()), None); + + let (pubport_descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + let descriptors: Descriptors = pubport_descriptors.into(); + + assert_eq!(address_type, WalletAddressType::WrappedSegwit); + assert_eq!( + descriptors.fingerprint().map(|fingerprint| fingerprint.to_string()), + Some("817e7be0".to_string()) + ); + assert_eq!( + xpub_from_descriptors(&descriptors).unwrap().to_string(), + "xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm" + ); + } + + #[test] + fn converted_json_import_material_matches_pubport_for_primary_descriptors() { + for pubport_descriptors in [bip84_descriptors(), bip44_descriptors()] { + let descriptors: Descriptors = pubport_descriptors.clone().into(); + + assert_eq!(descriptors.fingerprint(), pubport_descriptors.fingerprint()); + assert_eq!( + xpub_from_descriptors(&descriptors).unwrap(), + pubport_descriptors.xpub().unwrap() + ); + } + } + + #[test] + fn descriptor_address_type_infers_supported_descriptor_formats() { + for (pubport_descriptors, expected) in [ + (bip84_descriptors(), WalletAddressType::NativeSegwit), + (bip49_descriptors(), WalletAddressType::WrappedSegwit), + (bip44_descriptors(), WalletAddressType::Legacy), + ] { + let descriptors = Descriptors::from(pubport_descriptors); + + assert_eq!(address_type_from_descriptors(&descriptors).unwrap(), expected); + } + } + + #[test] + fn descriptor_address_type_rejects_taproot() { + let descriptors = Descriptors::from(bip86_descriptors()); + let error = address_type_from_descriptors(&descriptors).unwrap_err(); + + assert_eq!( + error, + WalletError::UnsupportedWallet("Taproot descriptors are not supported".to_string()) + ); + } + + #[test] + fn bare_zpub_import_selects_native_segwit_descriptor() { + let json = json_from_export(BIP84_ZPUB); + + assert!(json.bip84.is_some()); + assert!(json.bip49.is_none()); + + let (descriptors, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::NativeSegwit); + assert!(descriptors.external.to_string().starts_with("wpkh(")); + assert!(!should_start_json_discovery(&json, address_type)); + } + + #[test] + fn json_discovery_starts_for_native_segwit_with_alternates() { + let json = descriptor_json( + Some(bip44_descriptors()), + Some(bip49_descriptors()), + Some(bip84_descriptors()), + ); + + let (_, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::NativeSegwit); + assert!(should_start_json_discovery(&json, address_type)); + } + + #[test] + fn json_discovery_starts_for_wrapped_segwit_with_legacy_alternate() { + let json = descriptor_json(Some(bip44_descriptors()), Some(bip49_descriptors()), None); + + let (_, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::WrappedSegwit); + assert!(should_start_json_discovery(&json, address_type)); + } + + #[test] + fn json_discovery_does_not_start_for_single_native_segwit_export() { + let json = descriptor_json(None, None, Some(bip84_descriptors())); + + let (_, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::NativeSegwit); + assert!(!should_start_json_discovery(&json, address_type)); + } + + #[test] + fn json_discovery_does_not_start_for_wrapped_segwit_export() { + let json = descriptor_json(None, Some(bip49_descriptors()), None); + + let (_, address_type) = preferred_json_descriptors(&json).unwrap(); + + assert_eq!(address_type, WalletAddressType::WrappedSegwit); + assert!(!should_start_json_discovery(&json, address_type)); + } + + #[test] + fn cold_upgrade_metadata_carries_json_discovery_state() { + let json = descriptor_json( + Some(bip44_descriptors()), + Some(bip49_descriptors()), + Some(bip84_descriptors()), + ); + let descriptors = Descriptors::from(bip84_descriptors()); + + let mut existing_metadata = WalletMetadata::preview_new(); + existing_metadata.name = "Existing watch-only wallet".to_string(); + existing_metadata.wallet_type = WalletType::WatchOnly; + existing_metadata.discovery_state = DiscoveryState::Single; + existing_metadata.address_type = WalletAddressType::Legacy; + + let mut import_metadata = existing_metadata.clone(); + import_metadata.name = "Incoming hardware wallet".to_string(); + import_metadata.wallet_type = WalletType::Cold; + import_metadata.address_type = WalletAddressType::NativeSegwit; + import_metadata.discovery_state = + DiscoveryState::StartedJson(Arc::new(json.clone().into())); + + let updated = + metadata_for_cold_upgrade(existing_metadata.clone(), &import_metadata, &descriptors); + + assert_eq!(updated.name, existing_metadata.name); + assert_ne!(updated.name, import_metadata.name); + assert_eq!(updated.wallet_type, WalletType::Cold); + assert_eq!(updated.address_type, WalletAddressType::NativeSegwit); + assert_eq!(updated.origin, descriptors.origin().ok()); + + let DiscoveryState::StartedJson(found_json) = updated.discovery_state else { + panic!("expected JSON discovery to start after cold upgrade"); + }; + + assert_eq!(found_json.as_ref().0, json); + } + + #[test] + fn test_fingerprint() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + crate::database::test_support::delete_database(); + + let mnemonic = Mnemonic::parse_normalized( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + + let metadata = WalletMetadata::preview_new(); + + let wallet = + Wallet::try_new_persisted_from_mnemonic_segwit(metadata.clone(), mnemonic, None) + .unwrap(); + + let fingerprint = wallet.metadata.master_fingerprint.as_ref().unwrap().as_lowercase(); + + let _ = delete_wallet_specific_data(&metadata.id); + assert_eq!("73c5da0a", fingerprint.as_str()); + } +} diff --git a/rust/src/wallet/metadata.rs b/rust/src/wallet/metadata.rs index 81a56057d..7980504ae 100644 --- a/rust/src/wallet/metadata.rs +++ b/rust/src/wallet/metadata.rs @@ -11,6 +11,7 @@ use super::{AddressInfo, WalletAddressType, fingerprint::Fingerprint}; use crate::transaction::Unit; use crate::{database::Database, network::Network}; use cove_tap_card::TapSigner; +use cove_util::format::NumberFormatter as _; pub use cove_types::{BlockSizeLast, WalletId}; @@ -118,6 +119,16 @@ pub enum WalletBirthday { Timestamp(u64), } +#[uniffi::export] +impl WalletBirthday { + pub fn block_height_fmt(&self) -> Option { + match self { + Self::BlockHeight(height) => Some(height.thousands_int()), + Self::Timestamp(_) => None, + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, Hash, Eq, PartialEq, uniffi::Enum)] #[uniffi::export(Eq, Hash)] pub enum DiscoveryState { @@ -557,6 +568,17 @@ mod tests { } } + #[test] + fn wallet_birthday_block_height_formats_with_grouping() { + assert_eq!(WalletBirthday::BlockHeight(0).block_height_fmt(), Some("0".to_string())); + assert_eq!(WalletBirthday::BlockHeight(1).block_height_fmt(), Some("1".to_string())); + assert_eq!( + WalletBirthday::BlockHeight(1_000_000).block_height_fmt(), + Some("1,000,000".to_string()) + ); + assert_eq!(WalletBirthday::Timestamp(1_700_000_000).block_height_fmt(), None); + } + #[test] fn zero_tap_signer_birth_height_is_ignored() { assert_eq!(valid_birth_height(Some(0)), None);