Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The role of this file is to describe common mistakes and confusion points that a
- Use `cove_util::ResultExt::map_err_prefix` instead of `.map_err(|e| Error::Variant(format!("context: {e}")))` when the prefix is a static string — produces `"context: error_message"`
- For long-lived UI-facing managers, prefer `dispatch(action:)` for user intents and keep named methods for reads, bootstrap/lifecycle hooks, and special service-style operations, use `state()` for the initial snapshot only, and send typed delta reconcile messages for ongoing UI updates instead of re-sending the whole state after every mutation
- Prefer scoped blocks to release locks or borrows instead of explicit `drop(...)` unless explicit `drop` is actually needed
- Add blank lines between logical steps in function bodies across Rust, Swift, and Kotlin: after setup/result bindings before new control flow, after multi-line `if`/`match`/`guard`/`do`/`Task` blocks before the next independent statement, between state mutations and final returns, and between multi-line `match`/`switch` arms. Keep tightly related consecutive assignments together
- Put explanatory comments immediately above the statement or arm they describe, separated from the previous step by a blank line
- never use `pub(in ...)` or `pub(super)`; if non-private visibility is needed, use `pub(crate)` or `pub`
- never manually edit generated files
- no mod.rs files use the other format module_name.rs module_name/new_module.rs
57 changes: 54 additions & 3 deletions android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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
Expand All @@ -29,6 +30,8 @@ class AppManager private constructor() : FfiReconcile {

// Scope for UI-bound work; reconcile() hops to Main here
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var navigationGeneration = 0L
private var pendingSidebarNavigationJob: Job? = null

// rust bridge - not observable
internal var rust: FfiApp = FfiApp()
Expand Down Expand Up @@ -195,6 +198,10 @@ class AppManager private constructor() : FfiReconcile {
* clears all cached data and reinitializes
*/
fun reset() {
pendingSidebarNavigationJob?.cancel()
pendingSidebarNavigationJob = null
beginNavigationIntent()

// close managers before clearing them
walletManager?.close()
sendFlowManager?.close()
Expand All @@ -221,13 +228,28 @@ class AppManager private constructor() : FfiReconcile {
*/
fun selectWallet(id: WalletId) {
try {
rust.selectWallet(id)
isSidebarVisible = false
selectWalletOrThrow(id)
} catch (e: Exception) {
Log.e(tag, "Unable to select wallet $id", e)
}
}

@Throws(Exception::class)
fun selectWalletOrThrow(id: WalletId) {
beginNavigationIntent()
rust.selectWallet(id)
isSidebarVisible = false
}

fun selectLatestOrNewWallet() {
beginNavigationIntent()
try {
rust.selectLatestOrNewWallet()
} catch (e: Exception) {
Log.e(tag, "Unable to select latest wallet", e)
}
}

fun toggleSidebar() {
isSidebarVisible = !isSidebarVisible
}
Expand All @@ -237,14 +259,18 @@ class AppManager private constructor() : FfiReconcile {
}

fun closeSidebarAndNavigate(action: suspend () -> Unit) {
pendingSidebarNavigationJob?.cancel()
val generation = beginNavigationIntent()
isSidebarVisible = false
mainScope.launch {
pendingSidebarNavigationJob = mainScope.launch {
kotlinx.coroutines.delay(SIDEBAR_NAVIGATION_DELAY_MS)
if (!isNavigationGenerationCurrent(generation)) return@launch
action()
}
}

Comment thread
praveenperera marked this conversation as resolved.
fun pushRoute(route: Route) {
beginNavigationIntent()
Log.d(tag, "pushRoute: $route")
isSidebarVisible = false
val newRoutes = router.routes.toMutableList().apply { add(route) }
Expand All @@ -257,6 +283,7 @@ class AppManager private constructor() : FfiReconcile {
}

fun pushRoutes(routes: List<Route>) {
beginNavigationIntent()
Log.d(tag, "pushRoutes: ${routes.size} routes")
isSidebarVisible = false
val newRoutes = router.routes.toMutableList().apply { addAll(routes) }
Expand All @@ -269,6 +296,7 @@ class AppManager private constructor() : FfiReconcile {
}

fun popRoute() {
beginNavigationIntent()
Log.d(tag, "popRoute")
if (rust.canGoBack()) {
val newRoutes = router.routes.dropLast(1)
Expand All @@ -282,6 +310,7 @@ class AppManager private constructor() : FfiReconcile {
}

fun setRoute(routes: List<Route>) {
beginNavigationIntent()
Log.d(tag, "setRoute: ${routes.size} routes")

// only dispatch if routes actually changed
Expand All @@ -300,6 +329,7 @@ class AppManager private constructor() : FfiReconcile {
}

fun resetRoute(to: List<Route>) {
beginNavigationIntent()
if (to.size > 1) {
rust.resetNestedRoutesTo(to[0], to.drop(1))
} else if (to.isNotEmpty()) {
Expand All @@ -308,13 +338,34 @@ class AppManager private constructor() : FfiReconcile {
}

fun resetRoute(to: Route) {
beginNavigationIntent()
rust.resetDefaultRouteTo(to)
}

fun loadAndReset(to: Route) {
beginNavigationIntent()
rust.loadAndResetDefaultRoute(to)
}

fun captureLoadAndResetGeneration(): Long = navigationGeneration

fun resetAfterLoadingIfCurrent(
generation: Long,
route: Route.LoadAndReset,
nextRoutes: List<Route>,
) {
if (!isNavigationGenerationCurrent(generation)) return
if (router.default != route) return
rust.resetAfterLoading(nextRoutes)
}

private fun beginNavigationIntent(): Long {
navigationGeneration += 1
return navigationGeneration
}

private fun isNavigationGenerationCurrent(generation: Long): Boolean = generation == navigationGeneration

fun agreeToTerms() {
dispatch(AppAction.AcceptTerms)
isTermsAccepted = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class AuthManager private constructor() : AuthManagerReconciler {
app.isLoading = true

// select the latest (most recently used) wallet or navigate to new wallet flow
app.rust.selectLatestOrNewWallet()
app.selectLatestOrNewWallet()
}

/**
Expand Down
11 changes: 6 additions & 5 deletions android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ private fun GlobalAlertDialog(
TextButton(onClick = {
onDismiss()
try {
app.rust.selectWallet(state.walletId)
app.selectWalletOrThrow(state.walletId)
app.resetRoute(Route.SelectedWallet(state.walletId))
} catch (e: Exception) {
Log.e("GlobalAlert", "Failed to select wallet", e)
Expand Down Expand Up @@ -819,12 +819,13 @@ private fun GlobalAlertDialog(
text = { Text(state.message()) },
confirmButton = {
TextButton(onClick = {
onDismiss()
try {
app.rust.selectWallet(state.walletId)
app.selectWalletOrThrow(state.walletId)
onDismiss()
app.resetRoute(Route.SelectedWallet(state.walletId))
} catch (e: Exception) {
Log.e("GlobalAlert", "Failed to select wallet", e)
app.alertState = TaggedItem(AppAlertState.UnableToSelectWallet)
}
}) { Text("Yes") }
},
Expand Down Expand Up @@ -994,7 +995,7 @@ private fun GlobalAlertDialog(
try {
Wallet.newFromXpub(xpub = text.trim()).use { wallet ->
val id = wallet.id()
app.rust.selectWallet(id)
app.selectWalletOrThrow(id)
app.resetRoute(Route.SelectedWallet(id))
}
} catch (e: Exception) {
Expand Down Expand Up @@ -1055,7 +1056,7 @@ private fun GlobalAlertDialog(
}
TextButton(onClick = {
onDismiss()
app.rust.selectLatestOrNewWallet()
app.selectLatestOrNewWallet()
}) { Text("Cancel") }
}
},
Expand Down
20 changes: 8 additions & 12 deletions android/app/src/main/java/org/bitcoinppl/cove/RouteView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ fun RouteView(app: AppManager, route: Route) {
is Route.LoadAndReset -> {
LoadAndResetContainer(
app = app,
nextRoutes = route.resetTo.map { it.route() },
loadingTimeMs = route.afterMillis.toLong(),
route = route,
)
}
}
Expand All @@ -92,24 +91,21 @@ fun RouteView(app: AppManager, route: Route) {
@Composable
private fun LoadAndResetContainer(
app: AppManager,
nextRoutes: List<Route>,
loadingTimeMs: Long,
route: Route.LoadAndReset,
) {
val nextRoutes = route.resetTo.map { it.route() }
val loadingTimeMs = route.afterMillis.toLong()

// show loading indicator
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}

// execute reset after delay
LaunchedEffect(Unit) {
LaunchedEffect(route) {
val generation = app.captureLoadAndResetGeneration()
delay(loadingTimeMs)

if (nextRoutes.size > 1) {
// nested routes: first route is default, rest are nested
app.resetRoute(nextRoutes)
} else if (nextRoutes.isNotEmpty()) {
// single route becomes new default
app.resetRoute(nextRoutes[0])
}
app.resetAfterLoadingIfCurrent(generation, route, nextRoutes)
}
}
8 changes: 4 additions & 4 deletions android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ class ScanManager private constructor() {
val manager = ImportWalletManager()
try {
val walletMetadata = manager.rust.importWallet(listOf(words))
app.rust.selectWallet(walletMetadata.id)
app.selectWalletOrThrow(walletMetadata.id)
} catch (e: ImportWalletException.InvalidWordGroup) {
Log.d(tag, "Invalid word group detected")
app.alertState = TaggedItem(AppAlertState.InvalidWordGroup)
} catch (e: ImportWalletException.WalletAlreadyExists) {
Log.w(tag, "Attempted to import words for an existing hot wallet: ${e.v1}")
app.alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1))
try {
app.rust.selectWallet(e.v1)
app.selectWalletOrThrow(e.v1)
} catch (selectError: Exception) {
Log.e(tag, "Unable to select existing wallet", selectError)
}
Expand All @@ -132,7 +132,7 @@ class ScanManager private constructor() {
app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully)

if (app.walletManager?.id != id) {
app.rust.selectWallet(id)
app.selectWalletOrThrow(id)
}

if (app.walletManager?.id == id && app.walletManager?.walletMetadata?.walletType != WalletType.HOT) {
Expand All @@ -148,7 +148,7 @@ class ScanManager private constructor() {
} catch (e: WalletException.WalletAlreadyExists) {
app.alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1))
try {
app.rust.selectWallet(e.v1)
app.selectWalletOrThrow(e.v1)
} catch (selectError: Exception) {
Log.e(tag, "Unable to select existing wallet", selectError)
app.alertState = TaggedItem(AppAlertState.UnableToSelectWallet)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ fun NewWalletSelectScreen(
val id = wallet.id()
android.util.Log.d("NewWalletSelectScreen", "Imported Wallet: $id")

app.rust.selectWallet(id = id)
app.selectWalletOrThrow(id)
app.popRoute()
app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ fun ColdWalletQrScanScreen(app: AppManager, modifier: Modifier = Modifier) {
Log.d("ColdWalletQrScanScreen", "Imported Wallet: $id")

app.popRoute()
app.rust.selectWallet(id = id)
app.selectWalletOrThrow(id)
app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully)
} catch (e: WalletException.WalletAlreadyExists) {
app.popRoute()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fun QrCodeImportScreen(app: AppManager, modifier: Modifier = Modifier) {
wallet.close()
Log.d("QrCodeImportScreen", "Imported Wallet: $id")

app.rust.selectWallet(id = id)
app.selectWalletOrThrow(id)
app.popRoute()
app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully)
} catch (e: WalletException.WalletAlreadyExists) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,8 @@ fun HotWalletImportScreen(
try {
val walletMetadata = manager.importWallet(enteredWords)
app.clearWalletManager()
if (onImported != null) {
onImported.invoke(walletMetadata.id)
} else {
app.rust.selectWallet(walletMetadata.id)
onImported?.invoke(walletMetadata.id) ?: run {
app.selectWalletOrThrow(walletMetadata.id)
app.resetRoute(Route.SelectedWallet(walletMetadata.id))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (e: ImportWalletException.InvalidWordGroup) {
Expand Down Expand Up @@ -520,7 +518,7 @@ fun HotWalletImportScreen(
app.clearWalletManager()
manager.close()
onImported?.invoke(walletId) ?: run {
app.rust.selectWallet(walletId)
app.selectWalletOrThrow(walletId)
app.resetRoute(Route.SelectedWallet(walletId))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fun SelectedWalletContainer(
val otherWallet = wallets.firstOrNull { it.id != id }

if (otherWallet != null) {
app.rust.selectWallet(otherWallet.id)
app.selectWalletOrThrow(otherWallet.id)
} else {
app.loadAndReset(RouteFactory().newWalletSelect())
}
Expand All @@ -103,14 +103,13 @@ fun SelectedWalletContainer(

// start wallet scan after loading (matches iOS .task)
LaunchedEffect(manager) {
manager?.let { wm ->
try {
wm.rust.startWalletScan()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e(tag, "wallet scan failed: ${e.message}", e)
}
val wm = manager ?: return@LaunchedEffect
try {
wm.rust.startWalletScan()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e(tag, "wallet scan failed: ${e.message}", e)
}
}

Expand All @@ -122,10 +121,11 @@ fun SelectedWalletContainer(
}

// update app wallet manager when loaded
LaunchedEffect(manager?.loadState) {
val loadState = manager?.loadState
if (loadState is WalletLoadState.LOADED) {
manager?.let { app.setWalletManager(it) }
val loadedManager = manager
val loadState = loadedManager?.loadState
LaunchedEffect(loadedManager, loadState) {
if (loadedManager != null && loadState is WalletLoadState.LOADED) {
app.setWalletManager(loadedManager)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fun SelectedWalletScreen(
}
},
colors =
TopAppBarDefaults.centerAlignedTopAppBarColors(
TopAppBarDefaults.topAppBarColors(
// gradual fade from transparent to midnight blue based on scroll progress
containerColor = CoveColor.midnightBlue.copy(alpha = scrollProgress),
titleContentColor = Color.White,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ fun TransactionDetailsScreen(
topBar = {
CenterAlignedTopAppBar(
colors =
TopAppBarDefaults.centerAlignedTopAppBarColors(
TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = fg,
actionIconContentColor = fg,
Expand Down
Loading
Loading