Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
431c6eb
Fix iOS import words not autocompleting in some cases
praveenperera Feb 17, 2026
b50cfef
Update Android toolchain and dependencies
praveenperera Feb 16, 2026
6957c10
Detect and recover hot wallets with missing private keys
praveenperera Feb 6, 2026
f039a13
Send hot wallet key missing alert from Rust
praveenperera Feb 11, 2026
9c940b7
Upgrade watch-only wallet to cold on xpub import
praveenperera Feb 12, 2026
74cc4c4
Downgrade hot wallets with missing keys to watch-only instead of cold
praveenperera Feb 12, 2026
4ed2b18
Add watch-only wallet upgrade UI with import options
praveenperera Feb 12, 2026
d665b0c
Fix alert chaining and use pushRoute for watch-only wallet upgrade flow
praveenperera Feb 13, 2026
dbc5ed0
Fix error handling gaps in wallet type conversion and downgrade flow
praveenperera Feb 13, 2026
9fbf75c
Add debug-only "Simulate Missing Key" button to wallet settings
praveenperera Feb 13, 2026
f5a1c95
Skip key overwrite when importing mnemonic for an existing hot wallet
praveenperera Feb 13, 2026
ea9fdd6
Enforce local-only secure storage on iOS and Android
praveenperera Feb 17, 2026
c7ca7b2
Fix mnemonic import matching and duplicate import warnings
praveenperera Feb 17, 2026
edc4665
Clear wallet manager after wallet import
praveenperera Feb 17, 2026
58f2856
Android fix send button on watch-only wallets
praveenperera Feb 17, 2026
8512ebc
Fix wallet metadata not updating in cached WalletManager
praveenperera Feb 18, 2026
a58ab02
Upgrade watch-only wallet to cold in-place on hardware import
praveenperera Feb 18, 2026
31beada
Use app-level alertState for import success alerts
praveenperera Feb 18, 2026
e7d4338
Remove debug section from wallet settings on iOS and Android
praveenperera Feb 18, 2026
6b2e143
Simplify fingerprint checking
praveenperera Feb 18, 2026
5aeb1e2
Fix orphaned SQLite files, error-swallowing set_wallet_type, and unco…
praveenperera Feb 18, 2026
e34647b
Extract upgrade_to_cold helper to reduce nesting in pubport import
praveenperera Feb 18, 2026
7a9c6b2
Refactor import wallet handling and logs
praveenperera Feb 18, 2026
e3ea00d
Emit ClearCachedWalletManager after mnemonic import into existing wallet
praveenperera Feb 18, 2026
48cd7dc
Clarify walletManager reuse across screens
praveenperera Feb 18, 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
1 change: 1 addition & 0 deletions CLAUDE.md
34 changes: 15 additions & 19 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jlleitschuh.gradle.ktlint")
}
Expand Down Expand Up @@ -72,9 +71,6 @@ android {
buildConfig = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.3"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Expand All @@ -85,9 +81,9 @@ android {
dependencies {

implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
implementation("androidx.activity:activity-compose:1.12.0")
implementation(platform("androidx.compose:compose-bom:2025.08.01"))
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.4")
implementation(platform("androidx.compose:compose-bom:2025.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
Expand All @@ -96,37 +92,37 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.08.01"))
androidTestImplementation(platform("androidx.compose:compose-bom:2025.12.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

// Uniffi
implementation("net.java.dev.jna:jna:5.17.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")

// Jetpack compose / flow
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
implementation("androidx.compose.runtime:runtime-livedata:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.compose.runtime:runtime-livedata:1.10.1")
// lifecycle process for app-level lifecycle observation
implementation("androidx.lifecycle:lifecycle-process:2.9.3")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")

implementation("androidx.biometric:biometric-ktx:1.4.0-alpha02")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.security:security-crypto:1.1.0")

// Camera and QR scanning
implementation("androidx.camera:camera-camera2:1.5.1")
implementation("androidx.camera:camera-lifecycle:1.5.1")
implementation("androidx.camera:camera-view:1.5.1")
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
implementation("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0")
implementation("com.google.zxing:core:3.5.3")
implementation("com.google.accompanist:accompanist-permissions:0.37.3")
implementation("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1")
implementation("com.google.zxing:core:3.5.4")

// Coil for image loading (including SVG support)
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-svg:3.0.4")
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
implementation("io.coil-kt.coil3:coil-svg:3.3.0")

// Navigation 3 for idiomatic Android navigation
implementation("androidx.navigation3:navigation3-runtime:1.0.0")
Expand Down
31 changes: 30 additions & 1 deletion android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ class AppManager private constructor() : FfiReconcile {
return manager
}

fun clearWalletManager() {
try {
walletManager?.close()
} catch (e: Exception) {
Log.w(tag, "Error closing WalletManager: ${e.message}")
}
walletManager = null
}

fun clearSendFlowManager() {
try {
sendFlowManager?.close()
Expand Down Expand Up @@ -383,6 +392,7 @@ class AppManager private constructor() : FfiReconcile {
Log.d(tag, "Invalid word group detected")
alertState = TaggedItem(AppAlertState.InvalidWordGroup)
} catch (e: ImportWalletException.WalletAlreadyExists) {
Log.w(tag, "Attempted to import words for an existing hot wallet: ${e.v1}")
alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1))
try {
rust.selectWallet(e.v1)
Expand Down Expand Up @@ -410,7 +420,20 @@ class AppManager private constructor() : FfiReconcile {
val id = wallet.id()
Log.d(tag, "Imported Wallet: $id")
alertState = TaggedItem(AppAlertState.ImportedSuccessfully)
rust.selectWallet(id)

// if we're not already on this wallet, navigate to it
if (walletManager?.id != id) {
rust.selectWallet(id)
}

// upgrade watch-only → cold in-place
if (walletManager?.id == id && walletManager?.walletMetadata?.walletType != WalletType.HOT) {
try {
walletManager?.rust?.setWalletType(WalletType.COLD)
} catch (e: Exception) {
Log.e(tag, "Failed to set wallet type to cold", e)
}
}
} finally {
wallet.close()
}
Expand Down Expand Up @@ -606,6 +629,12 @@ class AppManager private constructor() : FfiReconcile {
wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() }
}

is AppStateReconcileMessage.ClearCachedWalletManager -> {
if (walletManager?.id == message.v1) {
clearWalletManager()
}
}

is AppStateReconcileMessage.ShowLoadingPopup -> {
alertState = TaggedItem(AppAlertState.Loading)
}
Expand Down
150 changes: 150 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@ import org.bitcoinppl.cove_core.AfterPinAction
import org.bitcoinppl.cove_core.AlertDisplayType
import org.bitcoinppl.cove_core.AppAction
import org.bitcoinppl.cove_core.AppAlertState
import org.bitcoinppl.cove_core.ColdWalletRoute
import org.bitcoinppl.cove_core.Database
import org.bitcoinppl.cove_core.HotWalletRoute
import org.bitcoinppl.cove_core.ImportType
import org.bitcoinppl.cove_core.NewWalletRoute
import org.bitcoinppl.cove_core.NumberOfBip39Words
import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.RouteFactory
import org.bitcoinppl.cove_core.TapSignerRoute
import org.bitcoinppl.cove_core.Wallet
import org.bitcoinppl.cove_core.WalletType
import org.bitcoinppl.cove_core.types.ColorSchemeSelection

class MainActivity : FragmentActivity() {
Expand Down Expand Up @@ -411,6 +417,57 @@ private fun GlobalAlertDialog(
)
}

is AppAlertState.HotWalletKeyMissing -> {
val walletId = state.walletId
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWELVE, ImportType.MANUAL))))
}) { Text("Import 12 Words") }
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.MANUAL))))
}) { Text("Import 24 Words") }
TextButton(onClick = {
onDismiss()
app.alertState = TaggedItem(AppAlertState.ConfirmWatchOnly)
}) { Text("Use as Watch Only") }
TextButton(onClick = {
onDismiss()
try {
app.getWalletManager(walletId).rust.setWalletType(WalletType.COLD)
} catch (e: Exception) {
Log.e("GlobalAlert", "Failed to set wallet type to cold", e)
app.alertState =
TaggedItem(
AppAlertState.General(
title = "Error",
message = e.message ?: "Failed to convert wallet",
),
)
}
}) { Text("Use with Hardware Wallet") }
}
},
)
}

is AppAlertState.ConfirmWatchOnly -> {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
TextButton(onClick = onDismiss) { Text("I Understand") }
},
)
}

is AppAlertState.NoCameraPermission -> {
val context = LocalContext.current
AlertDialog(
Expand Down Expand Up @@ -649,6 +706,99 @@ private fun GlobalAlertDialog(
)
}

is AppAlertState.CantSendOnWatchOnlyWallet -> {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(onClick = {
onDismiss()
app.alertState = TaggedItem(AppAlertState.WatchOnlyImportHardware)
}) { Text("Import Hardware Wallet") }
TextButton(onClick = {
onDismiss()
app.alertState = TaggedItem(AppAlertState.WatchOnlyImportWords)
}) { Text("Import Words") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}

is AppAlertState.WatchOnlyImportHardware -> {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.ColdWallet(ColdWalletRoute.QR_CODE)))
}) { Text("QR Code") }
TextButton(onClick = {
onDismiss()
app.scanNfc()
}) { Text("NFC") }
TextButton(onClick = {
onDismiss()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text =
clipboard.primaryClip
?.getItemAt(0)
?.text
?.toString()
if (!text.isNullOrBlank()) {
try {
val wallet = Wallet.newFromXpub(xpub = text.trim())
val id = wallet.id()
app.rust.selectWallet(id)
app.resetRoute(Route.SelectedWallet(id))
} catch (e: Exception) {
app.alertState =
TaggedItem(
AppAlertState.ErrorImportingHardwareWallet(e.message ?: "Unknown error"),
)
}
}
}) { Text("Paste") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}

is AppAlertState.WatchOnlyImportWords -> {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.QR))))
}) { Text("Scan QR") }
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.NFC))))
}) { Text("NFC") }
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWELVE, ImportType.MANUAL))))
}) { Text("12 Words") }
TextButton(onClick = {
onDismiss()
app.pushRoute(Route.NewWallet(NewWalletRoute.HotWallet(HotWalletRoute.Import(NumberOfBip39Words.TWENTY_FOUR, ImportType.MANUAL))))
}) { Text("24 Words") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}

else -> {
AlertDialog(
onDismissRequest = onDismiss,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ class WalletManager :
is WalletManagerReconcileMessage.SendFlowException -> {
sendFlowErrorAlert = TaggedItem(message.v1)
}

is WalletManagerReconcileMessage.HotWalletKeyMissing -> {
AppManager.getInstance().alertState = TaggedItem(AppAlertState.HotWalletKeyMissing(message.v1))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,13 @@ fun HotWalletImportScreen(
try {
val walletMetadata = manager.importWallet(enteredWords)
app.rust.selectWallet(walletMetadata.id)
app.clearWalletManager()
app.resetRoute(Route.SelectedWallet(walletMetadata.id))
} catch (e: ImportWalletException.InvalidWordGroup) {
Log.d("HotWalletImport", "Invalid word group while importing hot wallet")
alertState = AlertState.InvalidWords
} catch (e: ImportWalletException.WalletAlreadyExists) {
Log.w("HotWalletImport", "Attempted to import words for an existing hot wallet: ${e.v1}")
duplicateWalletId = e.v1
alertState = AlertState.DuplicateWallet
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ import org.bitcoinppl.cove.components.FullPageLoadingView
import org.bitcoinppl.cove.wallet.WalletExportState
import org.bitcoinppl.cove.wallet.WalletSheetsHost
import org.bitcoinppl.cove.wallet.rememberWalletExportLaunchers
import org.bitcoinppl.cove.TaggedItem
import org.bitcoinppl.cove_core.AppAlertState
import org.bitcoinppl.cove_core.Database
import org.bitcoinppl.cove_core.DiscoveryState
import org.bitcoinppl.cove_core.FoundAddress
import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.RouteFactory
import org.bitcoinppl.cove_core.SendRoute
import org.bitcoinppl.cove_core.WalletManagerAction
import org.bitcoinppl.cove_core.WalletType
import org.bitcoinppl.cove_core.types.WalletId

// delay to allow UI to settle before updating balance
Expand Down Expand Up @@ -184,7 +187,10 @@ fun SelectedWalletContainer(
},
canGoBack = canGoBack,
onSend = {
// check balance before navigating to send flow
if (wm.walletMetadata?.walletType == WalletType.WATCH_ONLY) {
app.alertState = TaggedItem(AppAlertState.CantSendOnWatchOnlyWallet)
return@SelectedWalletScreen
}
val balance = wm.balance.spendable().asSats()
if (balance > 0u.toULong()) {
app.pushRoute(Route.Send(SendRoute.SetAmount(id, null, null)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ fun SelectedWalletScreen(
// state for wallet name rename dropdown
var showRenameMenu by remember { mutableStateOf(false) }
val isColdWallet = manager?.walletMetadata?.walletType == WalletType.COLD
val isWatchOnly = manager?.walletMetadata?.walletType == WalletType.WATCH_ONLY

// force white status bar icons for midnight blue background
ForceLightStatusBarIcons()
Expand Down Expand Up @@ -417,6 +418,7 @@ fun SelectedWalletScreen(
onToggleSensitive = { manager?.dispatch(WalletManagerAction.ToggleSensitiveVisibility) },
onSend = onSend,
onReceive = onReceive,
isWatchOnly = isWatchOnly,
)
}

Expand Down
Loading
Loading