Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a331588
Run mobile unit tests in CI
praveenperera Jul 4, 2026
8eef94b
Split cloud backup ops tests into focused modules
praveenperera Jul 4, 2026
7387750
Add detekt with baseline for Android static analysis
praveenperera Jul 4, 2026
288e922
Split wallet.rs into builder and addressing modules
praveenperera Jul 4, 2026
e0a16bb
Add ReconcileChannel to dedupe manager reconcile plumbing
praveenperera Jul 4, 2026
14634a1
Replace stringly errors with typed sources in manager modules
praveenperera Jul 4, 2026
1b3f87e
Fix clippy lints in cove-ur and progressive scan
praveenperera Jul 4, 2026
6f5a952
Split ICloudDriveHelper into paths, coordination, and state machine u…
praveenperera Jul 4, 2026
7297f2d
Split AndroidCloudStorageAccess into http, resolver, and token units
praveenperera Jul 4, 2026
3e226d0
Fold cloud check atomics into onboarding flow state machine
praveenperera Jul 4, 2026
2eb00df
Move node and broadcast io off the wallet actor mailbox
praveenperera Jul 4, 2026
7dfc851
Decompose AppManager into navigation, cache, and background task units
praveenperera Jul 4, 2026
217f4b2
Move routing into RouterManager and split oversized Android screens
praveenperera Jul 4, 2026
73b1ff9
Move cloud backup types into dto and model modules
praveenperera Jul 4, 2026
0eddea0
Extract shared progressive scan core from electrum and esplora drivers
praveenperera Jul 4, 2026
25e1ab8
Extract alert and sheet presentation into feature-scoped files
praveenperera Jul 4, 2026
0edee09
Move fee rate and block height formatting into Rust core
praveenperera Jul 4, 2026
a0db7bf
Unify deep verification handlers behind continuation policy
praveenperera Jul 4, 2026
92f3412
Remove dead code and ceremonial internal enums
praveenperera Jul 4, 2026
29cee75
Adopt tagged presentation across remaining alert screens
praveenperera Jul 4, 2026
54b0a5b
Remove cloud backup mirror types and flatten retry ffi
praveenperera Jul 4, 2026
d7802ce
Remove inert manager ffi scaffolding
praveenperera Jul 4, 2026
64865d5
Extract Android manager cache and shared row helpers
praveenperera Jul 4, 2026
d5b3a25
Collapse outcome bridges and unify cloud backup write path
praveenperera Jul 4, 2026
79cde5e
Add refactor behavior tests
praveenperera Jul 6, 2026
9b49e84
Fix manual UI test flows
praveenperera Jul 6, 2026
5f73d33
Refactor custom fee and reconcile tests
praveenperera Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<dev.detekt.gradle.Detekt>().configureEach {
baseline.set(detektBaselineFile)
exclude("**/cove_core/**")
exclude("**/uniffi/**")
exclude("**/build/**")
}

tasks.withType<dev.detekt.gradle.DetektCreateBaselineTask>().configureEach {
baseline.set(detektBaselineFile)
exclude("**/cove_core/**")
exclude("**/uniffi/**")
exclude("**/build/**")
}

tasks.named("detekt") {
dependsOn("detektDevDebug")
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ class OnboardingFullLaunchTest {
.skipCloudBackupAfterImport()
.acceptTermsAfterImport()
.chooseNativeImportedWalletFromSelectionSheet()
.assertImportedMainnetWalletHasHistoryAndNoBitcoin()
.assertImportedMainnetWalletShowsHistory()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
Expand Down Expand Up @@ -614,7 +623,7 @@ private fun UiDevice.clickUntilVisible(

val clickNode = tryScrollUntilSafelyClickable(clickSelector)
if (clickNode?.isEnabled == true) {
clickCenter(clickNode)
clickNode.click()
}

Thread.sleep(500)
Expand Down Expand Up @@ -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(
Expand Down
194 changes: 194 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/AndroidManagerCache.kt
Original file line number Diff line number Diff line change
@@ -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>,
): 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()
}
}
}
}
Loading
Loading