From 8e345165b9fd4a69cfaea66051000e3a9679d212 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 24 Nov 2025 16:49:41 -0600 Subject: [PATCH 01/97] WIP --- .gitignore | 1 + android/app/build.gradle.kts | 7 + .../java/org/bitcoinppl/cove/MainActivity.kt | 15 +- .../org/bitcoinppl/cove/QrCodeScanView.kt | 46 ++-- .../cove/SelectedWalletContainer.kt | 25 +- .../cold_wallet/ColdWalletQrScanScreen.kt | 1 + .../hot_wallet/HotWalletCreateScreen.kt | 7 + .../hot_wallet/HotWalletImportScreen.kt | 177 +++++++++----- .../cove/send/CoinControlSetAmountScreen.kt | 6 +- .../org/bitcoinppl/cove/send/SendScreen.kt | 6 +- .../send/network_fee/NetworkFeeBottomSheet.kt | 6 +- .../SendConfirmationScreen.kt | 13 +- .../cove/settings/NodeSettingsScreen.kt | 14 +- .../cove/sidebar/SidebarContainer.kt | 32 ++- .../bitcoinppl/cove/sidebar/SidebarView.kt | 13 +- .../TransactionDetailsScreen.kt | 15 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 226 ++++++++++++++++++ .../org/bitcoinppl/cove/views/ImageButton.kt | 2 + .../WalletTransactionsScreen.kt | 18 +- justfile | 5 +- plan.md | 76 ++++++ plan_context.md | 96 ++++++++ plan_progress.md | 55 +++++ rust/xtask/src/android.rs | 26 +- rust/xtask/src/main.rs | 11 +- 25 files changed, 758 insertions(+), 141 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt create mode 100644 plan.md create mode 100644 plan_context.md create mode 100644 plan_progress.md diff --git a/.gitignore b/.gitignore index 8234764fd..1193be376 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ log.txt .claude *.part *.lck +*.salive diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8322cbb8d..85fd24182 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,9 +22,16 @@ android { } } + signingConfigs { + getByName("debug") { + // uses default debug keystore + } + } + buildTypes { release { isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 81acedeef..5a9fe87f6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -27,11 +27,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.isSystemInDarkTheme import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer import org.bitcoinppl.cove.ui.theme.CoveTheme import org.bitcoinppl.cove.views.LockView import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat +import org.bitcoinppl.cove_core.types.ColorSchemeSelection class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -42,8 +44,17 @@ class MainActivity : ComponentActivity() { TapCardNfcManager.getInstance().initialize(this) setContent { - CoveTheme { - val app = remember { AppManager.getInstance() } + val app = remember { AppManager.getInstance() } + + // compute dark theme based on user preference + val systemDarkTheme = isSystemInDarkTheme() + val darkTheme = when (app.colorSchemeSelection) { + ColorSchemeSelection.DARK -> true + ColorSchemeSelection.LIGHT -> false + ColorSchemeSelection.SYSTEM -> systemDarkTheme + } + + CoveTheme(darkTheme = darkTheme) { var initError by remember { mutableStateOf(null) } // initialize async runtime on start diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index c2d4f84b7..9508a137a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.CropFree import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -66,27 +67,30 @@ fun QrCodeScanView( onDismiss: () -> Unit, app: AppManager, modifier: Modifier = Modifier, + showTopBar: Boolean = true, ) { val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) Scaffold( topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = Color.White, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - ), - ) + if (showTopBar) { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + } }, containerColor = Color.Black, modifier = modifier.fillMaxSize(), @@ -285,6 +289,16 @@ private fun QrScannerContent( modifier = Modifier.fillMaxSize(), ) + // viewfinder overlay - centered + Icon( + imageVector = Icons.Outlined.CropFree, + contentDescription = "Scan area", + modifier = Modifier + .size(200.dp) + .align(Alignment.Center), + tint = Color.White.copy(alpha = 0.7f), + ) + // overlay content Column( modifier = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt index 1387dabb2..db6127da9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.bitcoinppl.cove.components.FullPageLoadingView import org.bitcoinppl.cove.nfc.NfcLabelImportSheet +import org.bitcoinppl.cove.wallet_transactions.ReceiveAddressSheet import org.bitcoinppl.cove.wallet_transactions.WalletMoreOptionsSheet import org.bitcoinppl.cove.wallet_transactions.WalletTransactionsScreen import org.bitcoinppl.cove_core.* @@ -131,8 +132,9 @@ fun SelectedWalletContainer( } } - // state for more options sheet + // state for sheets var showMoreOptions by remember { mutableStateOf(false) } + var showReceiveSheet by remember { mutableStateOf(false) } var showNfcScanner by remember { mutableStateOf(false) } var exportType by remember { mutableStateOf(null) } var isExporting by remember { mutableStateOf(false) } @@ -307,10 +309,18 @@ fun SelectedWalletContainer( }, canGoBack = canGoBack, onSend = { - app.pushRoute(Route.Send(SendRoute.SetAmount(id, null, null))) + // check balance before navigating to send flow + val balance = wm.balance.spendable().asSats() + if (balance > 0u.toULong()) { + app.pushRoute(Route.Send(SendRoute.SetAmount(id, null, null))) + } else { + scope.launch { + snackbarHostState.showSnackbar("No funds available to send") + } + } }, onReceive = { - // TODO: implement receive address screen/sheet + showReceiveSheet = true }, onQrCode = { app.scanQr() @@ -395,6 +405,15 @@ fun SelectedWalletContainer( ) } } + + // show receive address sheet + if (showReceiveSheet) { + ReceiveAddressSheet( + manager = wm, + snackbarHostState = snackbarHostState, + onDismiss = { showReceiveSheet = false }, + ) + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt index c41c61bc3..9b0221caa 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt @@ -69,6 +69,7 @@ fun ColdWalletQrScanScreen(app: AppManager, modifier: Modifier = Modifier) { ) { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { QrCodeScanView( + showTopBar = false, onScanned = { stringOrData -> try { // try to parse as string first for xpub/descriptor diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt index 72b58ab86..564a3767d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt @@ -90,6 +90,7 @@ fun HotWalletCreateScreen( var showBackConfirmation by remember { mutableStateOf(false) } var showSaveError by remember { mutableStateOf(false) } var saveErrorMessage by remember { mutableStateOf("") } + var isSaving by remember { mutableStateOf(false) } // sync page state LaunchedEffect(pagerState.currentPage) { @@ -99,6 +100,10 @@ fun HotWalletCreateScreen( val isLastPage = currentPage == groupedWords.size - 1 fun handleSaveWallet() { + // guard against multiple calls + if (isSaving) return + isSaving = true + try { val walletId = manager.rust.saveWallet().id app.resetRoute( @@ -111,6 +116,7 @@ fun HotWalletCreateScreen( Log.e("HotWalletCreate", "error saving wallet", e) saveErrorMessage = e.message ?: "Unknown error occurred" showSaveError = true + isSaving = false } } @@ -274,6 +280,7 @@ fun HotWalletCreateScreen( } } }, + enabled = !isSaving, colors = ButtonDefaults.buttonColors( containerColor = CoveColor.btnPrimary, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index e51e7c608..d91bae22b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -10,6 +10,7 @@ import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed @@ -482,6 +484,7 @@ private fun WordInputGrid( number = index + 1, word = word, numberOfWords = numberOfWords, + allEnteredWords = enteredWords, isFocused = focusedField == index, onWordChanged = { newWord -> val groupIndex = index / GROUPS_OF @@ -510,6 +513,7 @@ private fun WordInputField( number: Int, word: String, numberOfWords: NumberOfBip39Words, + allEnteredWords: List>, isFocused: Boolean, onWordChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, @@ -523,6 +527,8 @@ private fun WordInputField( ) } + var suggestions by remember { mutableStateOf>(emptyList()) } + val isValid = word.isNotEmpty() && autocomplete.isBip39Word(word) val hasInput = word.isNotEmpty() @@ -550,69 +556,118 @@ private fun WordInputField( } } - Row( - modifier = - Modifier - .fillMaxWidth() - .height(40.dp) - .background( - color = Color.White.copy(alpha = 0.08f), - shape = RoundedCornerShape(8.dp), - ).border( - width = if (borderColor != Color.Transparent) 2.dp else 0.dp, - color = borderColor, - shape = RoundedCornerShape(8.dp), - ).padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "$number.", - color = CoveColor.coveLightGray.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.width(28.dp), - ) - - BasicTextField( - value = word, - onValueChange = { newValue -> - val trimmed = newValue.trim().lowercase() - onWordChanged(trimmed) - - // auto-advance when word is complete and valid - if (trimmed.isNotEmpty() && autocomplete.isBip39Word(trimmed)) { - onNext() - } - }, - textStyle = - TextStyle( - color = textColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, - ), - singleLine = true, - cursorBrush = SolidColor(Color.White), - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { onNext() }, - ), + // update suggestions when word or focus changes + LaunchedEffect(word, isFocused) { + suggestions = if (isFocused && word.isNotEmpty()) { + autocomplete.autocomplete(word, allEnteredWords) + } else { + emptyList() + } + } + + Column { + Row( modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - onFocusChanged(focusState.isFocused) - }, - ) + .fillMaxWidth() + .height(40.dp) + .background( + color = Color.White.copy(alpha = 0.08f), + shape = RoundedCornerShape(8.dp), + ).border( + width = if (borderColor != Color.Transparent) 2.dp else 0.dp, + color = borderColor, + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$number.", + color = CoveColor.coveLightGray.copy(alpha = 0.6f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(28.dp), + ) + + BasicTextField( + value = word, + onValueChange = { newValue -> + val trimmed = newValue.trim().lowercase() + onWordChanged(trimmed) + + // auto-advance when word is complete and valid + if (trimmed.isNotEmpty() && autocomplete.isBip39Word(trimmed)) { + suggestions = emptyList() + onNext() + } + }, + textStyle = + TextStyle( + color = textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, + ), + singleLine = true, + cursorBrush = SolidColor(Color.White), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { onNext() }, + ), + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + onFocusChanged(focusState.isFocused) + if (!focusState.isFocused) { + suggestions = emptyList() + } + }, + ) + } + + // suggestion dropdown + if (suggestions.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = CoveColor.midnightBlue.copy(alpha = 0.95f), + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + ) + .border( + width = 1.dp, + color = CoveColor.coveLightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + ), + ) { + suggestions.forEach { suggestion -> + Text( + text = suggestion, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .fillMaxWidth() + .clickable { + onWordChanged(suggestion) + suggestions = emptyList() + onNext() + } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) + } + } + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt index ff25f4f82..5140ea649 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.ImageButton @OptIn(ExperimentalMaterial3Api::class) @@ -210,10 +211,11 @@ private fun BalanceWidget( ) Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.Bottom) { - Text( + AutoSizeText( text = if (isHidden) "••••••" else amount, color = Color.White, - fontSize = 24.sp, + maxFontSize = 24.sp, + minimumScaleFactor = 0.90f, fontWeight = FontWeight.Bold, ) Spacer(Modifier.size(6.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index 40c0dd9ee..7245d77b6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.ImageButton @Preview() @@ -214,10 +215,11 @@ private fun BalanceWidget( ) Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.Bottom) { - Text( + AutoSizeText( text = if (isHidden) "••••••" else amount, color = Color.White, - fontSize = 24.sp, + maxFontSize = 24.sp, + minimumScaleFactor = 0.90f, fontWeight = FontWeight.Bold, ) Spacer(Modifier.size(6.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt index 3cbfb5666..7ad8d3b64 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton import java.util.Locale @@ -404,10 +405,11 @@ fun CustomNetworkFeeBottomSheet( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( + BalanceAutoSizeText( text = String.format(Locale.US, "%.2f", feeRate), color = MaterialTheme.colorScheme.onSurface, - fontSize = 48.sp, + baseFontSize = 48.sp, + minFontSize = 24.sp, fontWeight = FontWeight.Bold, ) Row( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt index 6f535069d..f5519a884 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt @@ -45,6 +45,8 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.R import org.bitcoinppl.cove.SendState import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.BalanceAutoSizeText // Animation duration in milliseconds for swipe button returning to start position when swipe is incomplete. private const val SWIPE_RETURN_DURATION_MS = 500 @@ -266,12 +268,12 @@ private fun AmountWidget( verticalAlignment = Alignment.Bottom, ) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text( + BalanceAutoSizeText( text = amount, color = MaterialTheme.colorScheme.onSurface, - fontSize = 48.sp, + baseFontSize = 48.sp, + minFontSize = 24.sp, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Right, modifier = Modifier.fillMaxWidth(), ) } @@ -288,10 +290,11 @@ private fun AmountWidget( } } Spacer(Modifier.height(12.dp)) - Text( + AutoSizeText( dollarText, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 20.sp, + maxFontSize = 20.sp, + minimumScaleFactor = 0.90f, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt index 228ed7f11..f2545379b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt @@ -142,9 +142,12 @@ fun NodeSettingsScreen( } selectedNodeSelection = NodeSelection.Preset(node) - snackbarHostState.showSnackbar( - successConnected.format(node.url), - ) + // launch snackbar in separate coroutine so it doesn't block finally + scope.launch { + snackbarHostState.showSnackbar( + successConnected.format(node.url), + ) + } } catch (e: NodeSelectorException.NodeNotFound) { errorTitle = errorTitleDefault errorMessage = errorNotFound.format(e.v1) @@ -189,7 +192,10 @@ fun NodeSettingsScreen( selectedNodeSelection = NodeSelection.Custom(node) selectedNodeName = node.name - snackbarHostState.showSnackbar(successSaved) + // launch snackbar in separate coroutine so it doesn't block finally + scope.launch { + snackbarHostState.showSnackbar(successSaved) + } } catch (e: NodeSelectorException.ParseNodeUrlException) { errorTitle = errorParseTitle errorMessage = e.v1 diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index dd8fb308b..0eaef9a5d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -1,7 +1,8 @@ package org.bitcoinppl.cove.sidebar +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box @@ -24,7 +25,7 @@ import org.bitcoinppl.cove.AppManager import kotlin.math.roundToInt private const val SIDEBAR_WIDTH_DP = 280f -private const val ANIMATION_DURATION_MS = 300 +private const val EDGE_SWIPE_THRESHOLD_DP = 50f @Composable fun SidebarContainer( @@ -46,10 +47,13 @@ fun SidebarContainer( // calculate target offset based on sidebar visibility val targetOffset = if (app.isSidebarVisible) sidebarWidthPx else 0f - // animate the offset when not dragging + // animate the offset when not dragging - use spring for natural feel val animatedOffset by animateFloatAsState( - targetValue = if (isDragging) targetOffset else targetOffset, - animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), label = "sidebarOffset", ) @@ -103,8 +107,8 @@ fun SidebarContainer( if (gesturesEnabled) { detectHorizontalDragGestures( onDragStart = { offset -> - // only start drag from left edge when closed - if (!app.isSidebarVisible && offset.x < 25.dp.toPx()) { + // only start drag from left edge when closed (using threshold constant) + if (!app.isSidebarVisible && offset.x < edgeThresholdPx) { isDragging = true gestureOffset = 0f } else if (app.isSidebarVisible) { @@ -114,13 +118,18 @@ fun SidebarContainer( }, onDragEnd = { if (isDragging) { + isDragging = false + // determine if we should complete the open/close action - val threshold = sidebarWidthPx * 0.5f + val threshold = sidebarWidthPx * 0.3f val finalOffset = targetOffset + gestureOffset + val shouldBeOpen = finalOffset > threshold - app.isSidebarVisible = finalOffset > threshold + // only update if state actually changes + if (app.isSidebarVisible != shouldBeOpen) { + app.isSidebarVisible = shouldBeOpen + } - isDragging = false gestureOffset = 0f } }, @@ -130,8 +139,7 @@ fun SidebarContainer( }, onHorizontalDrag = { _, dragAmount -> if (isDragging) { - // apply dampening factor (90%) - gestureOffset += dragAmount * 0.9f + gestureOffset += dragAmount // constrain gesture offset val proposedOffset = targetOffset + gestureOffset diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index cfbfe736f..9708814ef 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -61,7 +64,8 @@ fun SidebarView( .width(280.dp) .fillMaxHeight() .background(CoveColor.midnightBlue) - .padding(20.dp), + .padding(WindowInsets.safeDrawing.asPaddingValues()) + .padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { // header with icon and NFC button @@ -232,12 +236,11 @@ private fun WalletItem( ) // wallet name - Text( + AutoSizeText( text = wallet.name ?: "Wallet", color = Color.White, - fontSize = 17.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + maxFontSize = 17.sp, + minimumScaleFactor = 0.90f, modifier = Modifier.weight(1f), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 596c260ad..7cf3517e1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -74,6 +74,8 @@ 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.views.AutoSizeText +import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.WalletManagerAction @@ -339,20 +341,21 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(32.dp)) - Text( + BalanceAutoSizeText( txAmountPrimary, color = fg, - fontSize = 36.sp, + baseFontSize = 36.sp, + minFontSize = 24.sp, fontWeight = FontWeight.ExtraBold, - lineHeight = 44.sp, ) Spacer(Modifier.height(4.dp)) - Text( + AutoSizeText( txAmountSecondary, color = fg, - fontSize = 18.sp, + maxFontSize = 18.sp, + minimumScaleFactor = 0.90f, ) Spacer(Modifier.height(32.dp)) @@ -717,7 +720,7 @@ private fun DetailsWidget( } } Column(horizontalAlignment = Alignment.End) { - Text(primary, color = primaryColor, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + AutoSizeText(primary, color = primaryColor, maxFontSize = 18.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.SemiBold) if (!secondary.isNullOrEmpty()) { Spacer(Modifier.height(6.dp)) Text(secondary, color = sub, fontSize = 14.sp) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt new file mode 100644 index 000000000..fb80a9be7 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -0,0 +1,226 @@ +package org.bitcoinppl.cove.views + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import kotlin.math.floor +import kotlin.math.log10 + +/** + * Text composable that automatically resizes to fit available width + * Mimics iOS minimumScaleFactor behavior + * + * @param text The text to display + * @param modifier Modifier for the text + * @param color Text color + * @param maxFontSize Maximum font size (will shrink from this) + * @param minimumScaleFactor Minimum scale factor (0.0 to 1.0). Text will shrink to this percentage + * @param fontWeight Font weight + * @param fontStyle Font style + * @param fontFamily Font family + * @param textAlign Text alignment + * @param textDecoration Text decoration + * @param style Additional text style + * @param onTextLayout Callback for text layout + */ +@Composable +fun AutoSizeText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + maxFontSize: TextUnit = 14.sp, + minimumScaleFactor: Float = 0.9f, + fontWeight: FontWeight? = null, + fontStyle: FontStyle? = null, + fontFamily: FontFamily? = null, + textAlign: TextAlign? = null, + textDecoration: TextDecoration? = null, + style: TextStyle = LocalTextStyle.current, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + val density = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + val minFontSize = maxFontSize * minimumScaleFactor + + BoxWithConstraints(modifier = modifier) { + val maxWidthPx = with(density) { maxWidth.toPx() } + + // calculate optimal font size + val fontSize = remember(text, maxWidthPx, maxFontSize, minFontSize) { + calculateOptimalFontSize( + text = text, + maxFontSize = maxFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + } + + Text( + text = text, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + textAlign = textAlign, + textDecoration = textDecoration, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + onTextLayout = onTextLayout, + ) + } +} + +/** + * Balance text that uses digit-based font size reduction + * Matches iOS implementation: base font reduces by 2sp per digit, minimum 20sp + * + * @param text The balance text to display + * @param modifier Modifier for the text + * @param color Text color + * @param baseFontSize Base font size (default 34.sp to match iOS) + * @param minFontSize Minimum font size (default 20.sp to match iOS) + * @param fontWeight Font weight + * @param style Additional text style + * @param onTextLayout Callback for text layout + */ +@Composable +fun BalanceAutoSizeText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + baseFontSize: TextUnit = 34.sp, + minFontSize: TextUnit = 20.sp, + fontWeight: FontWeight? = null, + style: TextStyle = LocalTextStyle.current, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + val density = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + + // extract numeric value to count digits (matches iOS algorithm) + val digits = remember(text) { + val numericText = text.replace(Regex("[^0-9.]"), "") + val number = numericText.toDoubleOrNull() ?: 0.0 + if (number > 0) { + floor(log10(number)).toInt() + 1 + } else { + 1 + } + } + + // calculate font size based on digits: max(baseFontSize - (digits - 1) * 2, minFontSize) + val digitBasedFontSize = remember(digits, baseFontSize, minFontSize) { + val reduction = (digits - 1) * 2 + val calculated = baseFontSize.value - reduction + maxOf(calculated, minFontSize.value).sp + } + + BoxWithConstraints(modifier = modifier) { + val maxWidthPx = with(density) { maxWidth.toPx() } + + // ensure the calculated size still fits, otherwise shrink further + val finalFontSize = remember(text, maxWidthPx, digitBasedFontSize, minFontSize) { + calculateOptimalFontSize( + text = text, + maxFontSize = digitBasedFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = null, + fontFamily = null, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + } + + Text( + text = text, + color = color, + fontSize = finalFontSize, + fontWeight = fontWeight, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + onTextLayout = onTextLayout, + ) + } +} + +/** + * Calculate optimal font size using binary search + */ +private fun calculateOptimalFontSize( + text: String, + maxFontSize: TextUnit, + minFontSize: TextUnit, + maxWidthPx: Float, + style: TextStyle, + fontWeight: FontWeight?, + fontStyle: FontStyle?, + fontFamily: FontFamily?, + density: Density, + fontFamilyResolver: FontFamily.Resolver, +): TextUnit { + var low = minFontSize.value + var high = maxFontSize.value + var optimalSize = minFontSize.value + + // binary search for optimal font size + while (low <= high) { + val mid = (low + high) / 2f + val testStyle = style.copy( + fontSize = mid.sp, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + ) + + // measure text width at this font size + val paragraph = androidx.compose.ui.text.Paragraph( + text = text, + style = testStyle, + constraints = androidx.compose.ui.unit.Constraints(maxWidth = Int.MAX_VALUE), + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + + if (paragraph.minIntrinsicWidth <= maxWidthPx) { + // text fits, try larger size + optimalSize = mid + low = mid + 0.5f + } else { + // text doesn't fit, try smaller size + high = mid - 0.5f + } + } + + return optimalSize.sp +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index 0acd88c51..1466eb235 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -28,12 +28,14 @@ fun ImageButton( leading: @Composable (() -> Unit)? = null, onClick: () -> Unit, colors: ButtonColors = ButtonDefaults.buttonColors(), + enabled: Boolean = true, ) { Button( onClick = onClick, shape = RoundedCornerShape(10.dp), colors = colors, modifier = modifier, + enabled = enabled, contentPadding = PaddingValues(vertical = 18.dp, horizontal = 12.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 52afcde35..56f848347 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -56,6 +56,8 @@ import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletLoadState import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.Transaction import org.bitcoinppl.cove_core.types.TransactionDirection @@ -392,7 +394,7 @@ private fun TransactionWidget( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -415,7 +417,7 @@ private fun TransactionWidget( Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), ) { Text( text = title, @@ -423,10 +425,11 @@ private fun TransactionWidget( fontSize = 16.sp, fontWeight = FontWeight.Medium, ) - Text( + AutoSizeText( text = date, color = secondaryText, - fontSize = 14.sp, + maxFontSize = 14.sp, + minimumScaleFactor = 0.90f, fontWeight = FontWeight.Normal, ) } @@ -456,7 +459,7 @@ private fun BalanceWidget( ) { var isHidden by remember { mutableStateOf(hidden) } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = if (isHidden) "$———" else usdAmount, color = Color.White.copy(alpha = 0.7f), @@ -464,10 +467,11 @@ private fun BalanceWidget( ) Row(verticalAlignment = Alignment.CenterVertically) { - Text( + BalanceAutoSizeText( text = if (isHidden) "•••••• SATS" else satsAmount, color = Color.White, - fontSize = 36.sp, + baseFontSize = 34.sp, + minFontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) diff --git a/justfile b/justfile index a242f02df..848437b68 100644 --- a/justfile +++ b/justfile @@ -124,8 +124,9 @@ alias bar := build-android-release build-android-release: cd rust && cargo xtask build-android release -run-android: - cd rust && cargo xtask run-android +alias ra := run-android +run-android profile="debug": + cd rust && cargo xtask run-android {{profile}} compile-android: cd android && ./gradlew assembleDebug diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..aba1be733 --- /dev/null +++ b/plan.md @@ -0,0 +1,76 @@ +# Android iOS Parity Fixes - Implementation Plan + +## Issues Summary (Quick Wins First) + +| # | Issue | Complexity | Files | +|---|-------|------------|-------| +| 1 | Node spinner keeps spinning | Quick | `NodeSettingsScreen.kt` | +| 2 | Sidebar safe area overlap | Quick | `SidebarView.kt` | +| 3 | Receive button not implemented | Quick | `SelectedWalletContainer.kt` | +| 4 | Dark/Light mode not working | Medium | `MainActivity.kt`, `Theme.kt` | +| 5 | Send button missing validation | Medium | `SelectedWalletContainer.kt` | +| 6 | QR scanner dual back buttons | Medium | `QrCodeScanView.kt`, `ColdWalletQrScanScreen.kt` | +| 7 | QR scanner missing viewfinder | Medium | `QrCodeScanView.kt` | +| 8 | Sidebar gesture glitchy | Complex | `SidebarContainer.kt` | +| 9 | Save wallet infinite loop | Complex | `HotWalletCreateScreen.kt`, routing logic | +| 10 | Import wallet missing autocomplete | Complex | `HotWalletImportScreen.kt` (new component) | + +--- + +## Fix 1: Node Spinner Keeps Spinning +**File:** `android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt` +**Action:** Investigate if there's a secondary loading state or race condition + +--- + +## Fix 2: Sidebar Safe Area Overlap +**File:** `android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt` +**Solution:** Add `WindowInsets.safeDrawing.asPaddingValues()` to root Column + +--- + +## Fix 3: Receive Button Not Implemented +**Files:** `SelectedWalletContainer.kt`, `ReceiveAddressSheet.kt` +**Solution:** Add state variable and wire up existing sheet component + +--- + +## Fix 4: Dark/Light Mode Not Working +**Files:** `MainActivity.kt`, `Theme.kt` +**Solution:** Pass `app.colorSchemeSelection` to `CoveTheme` + +--- + +## Fix 5: Send Button Missing Validation +**File:** `SelectedWalletContainer.kt` +**Solution:** Check balance before navigating to send flow + +--- + +## Fix 6: QR Scanner Dual Back Buttons +**Files:** `QrCodeScanView.kt`, `ColdWalletQrScanScreen.kt` +**Solution:** Remove TopAppBar from QrCodeScanView, let parents provide navigation + +--- + +## Fix 7: QR Scanner Missing Viewfinder +**File:** `QrCodeScanView.kt` +**Solution:** Add viewfinder icon overlay centered on camera preview + +--- + +## Fix 8: Sidebar Gesture Glitchy +**File:** `SidebarContainer.kt` +**Solution:** Replace with Material3 NavigationDrawer or debug double-trigger + +--- + +## Fix 9: Save Wallet Infinite Loop +**Files:** `HotWalletCreateScreen.kt`, `NewHotWalletContainer.kt`, `AppManager.kt` +**Solution:** Investigate routing/reconciliation issue, compare with iOS + +--- + +## Fix 10: Import Wallet Missing BIP39 Autocomplete +**Files:** `HotWalletImportScreen.kt`, new `Bip39AutocompleteDropdown.kt` +**Solution:** Create dropdown suggestion component using Rust BIP39 word list diff --git a/plan_context.md b/plan_context.md new file mode 100644 index 000000000..dd9390f12 --- /dev/null +++ b/plan_context.md @@ -0,0 +1,96 @@ +# Android iOS Parity - Context & Learnings + +## Architecture Overview + +- **Rust core** in `rust/` - single source of truth for wallet logic +- **UniFFI bindings** generate Swift/Kotlin from Rust +- **iOS** uses SwiftUI with `@Observable` managers +- **Android** uses Jetpack Compose with same manager pattern +- Both aim for "consistent architecture, platform-native experience" + +--- + +## Key Files Reference + +### Android +- `MainActivity.kt` - App entry, CoveTheme wrapper, sheet handling +- `AppManager.kt` - Singleton state manager, routing, reconciliation +- `SidebarContainer.kt` - Custom drawer with gesture handling +- `SidebarView.kt` - Sidebar content (logo, wallets, settings) +- `SelectedWalletContainer.kt` - Wallet screen with send/receive +- `QrCodeScanView.kt` - Camera QR scanner +- `HotWalletCreateScreen.kt` - Wallet creation flow +- `HotWalletImportScreen.kt` - Import wallet with word input +- `NodeSettingsScreen.kt` - Node selection settings +- `AppearanceSettingsScreen.kt` - Theme settings +- `Theme.kt` - CoveTheme with color schemes + +### iOS (for comparison) +- `SelectedWalletContainer.swift` - iOS receive/send patterns +- `HotWalletCreateScreen.swift` - iOS save wallet flow +- `HotWalletImportScreen.swift` - iOS autocomplete implementation +- `ScannerView.swift` - iOS viewfinder implementation + +--- + +## Key Findings + +### Sidebar Issues +- Custom gesture handler with 90% dampening (SidebarContainer.kt:127) +- Edge detection from left 25dp only (SidebarContainer.kt:100) +- No WindowInsets padding - content goes under system bars +- Settings button at bottom overlaps navigation bar + +### QR Scanner Issues +- Both ColdWalletQrScanScreen AND QrCodeScanView have TopAppBar +- When nested, both back buttons appear +- iOS has viewfinder icon, Android doesn't + +### Theme Issues +- CoveTheme called without darkTheme parameter in MainActivity +- Uses `isSystemInDarkTheme()` default +- `app.colorSchemeSelection` tracked but never used by theme + +### Receive Button +- Handler is literally `// TODO: implement receive address screen/sheet` +- ReceiveAddressSheet.kt already exists and works + +### Save Wallet Loop +- Calls `app.resetRoute()` with nested routes +- May trigger re-creation of container, causing loop +- Need to compare with iOS implementation + +### BIP39 Autocomplete +- iOS has `Bip39AutoComplete` class and keyboard accessory +- Android has plain BasicTextField with no suggestions +- User prefers dropdown approach (not keyboard accessory) + +--- + +## Questions/Unknowns + +1. **Node spinner** - Code looks correct with `finally { isLoading = false }`. Need to verify if there's a race condition or secondary state. + +2. **Sidebar gesture** - Replace with Material3 NavigationDrawer, or debug existing? Need to verify design requirements. + +3. **Save wallet loop** - Need to investigate exact routing flow and compare with iOS. + +--- + +## Rust FFI Types Used + +- `ColorSchemeSelection` - DARK, LIGHT, SYSTEM enum +- `WalletBalance` - Has `confirmedBalance()` method +- `Route`, `SendRoute`, `HotWalletRoute` - Navigation types +- `NodeSelector`, `NodeSelection` - Node settings +- BIP39 word list available from `cove-bip39` crate + +--- + +## Android Patterns to Follow + +- State: Use `mutableStateOf` with callbacks (not MutableState params) +- Sheets: `ModalBottomSheet` with `rememberModalBottomSheetState` +- Insets: `WindowInsets.safeDrawing.asPaddingValues()` +- Coroutines: `rememberCoroutineScope()` + `launch { }` +- IO work: `withContext(Dispatchers.IO) { }` diff --git a/plan_progress.md b/plan_progress.md new file mode 100644 index 000000000..c1e85d844 --- /dev/null +++ b/plan_progress.md @@ -0,0 +1,55 @@ +# Android iOS Parity - Progress Tracker + +## Status Legend +- ⬜ Not started +- 🔄 In progress +- ✅ Completed +- ❌ Blocked + +--- + +## Quick Wins + +| # | Issue | Status | Notes | +|---|-------|--------|-------| +| 1 | Node spinner | ✅ | Fixed: showSnackbar blocking finally block | +| 2 | Sidebar safe area | ✅ | Added WindowInsets.safeDrawing padding | +| 3 | Receive button | ✅ | Wired up existing ReceiveAddressSheet | +| 4 | Dark/Light mode | ✅ | Pass colorSchemeSelection to CoveTheme | +| 5 | Send validation | ✅ | Check balance before navigation | + +## Medium Complexity + +| # | Issue | Status | Notes | +|---|-------|--------|-------| +| 6 | QR dual back buttons | ✅ | Added showTopBar param to QrCodeScanView | +| 7 | QR viewfinder | ✅ | Added CropFree icon overlay | + +## Complex + +| # | Issue | Status | Notes | +|---|-------|--------|-------| +| 8 | Sidebar gesture | ✅ | Spring animation, wider edge threshold, state guards | +| 9 | Save wallet loop | ✅ | Added isSaving guard, disabled button while saving | +| 10 | BIP39 autocomplete | ✅ | Dropdown suggestions below input field | + +--- + +## Change Log + +### Session 1 - All Fixes Complete +- All 10 fixes implemented and build successful +- Complex fixes completed: + - Sidebar: Spring animation, 50dp edge threshold, 30% open threshold + - Save wallet: isSaving guard prevents multiple calls + - Autocomplete: Dropdown with suggestions from Bip39WordSpecificAutocomplete + +### Session 1 - Implementation +- Completed 7 quick/medium fixes +- Build successful after all changes + +### Session 1 - Initial Planning +- Created plan from user's defect list +- Identified 10 issues +- Organized by complexity (quick wins first) +- User chose dropdown suggestions for autocomplete (not keyboard accessory) diff --git a/rust/xtask/src/android.rs b/rust/xtask/src/android.rs index e7be2ff05..cac8fdbf3 100644 --- a/rust/xtask/src/android.rs +++ b/rust/xtask/src/android.rs @@ -17,7 +17,8 @@ const COVE_CORE_PACKAGE_PATH: &str = "org/bitcoinppl/cove_core"; // Android run constants const ANDROID_PACKAGE_NAME: &str = "org.bitcoinppl.cove"; const ANDROID_ACTIVITY_NAME: &str = ".MainActivity"; -const APK_PATH: &str = "app/build/outputs/apk/debug/app-debug.apk"; +const APK_PATH_DEBUG: &str = "app/build/outputs/apk/debug/app-debug.apk"; +const APK_PATH_RELEASE: &str = "app/build/outputs/apk/release/app-release.apk"; #[derive(Debug, Clone, Copy)] pub enum BuildProfile { @@ -29,8 +30,8 @@ pub enum BuildProfile { impl BuildProfile { pub fn from_str(s: &str) -> Self { match s { - "debug" | "--debug" => Self::Debug, - "release" | "--release" => Self::Release, + "debug" | "--debug" | "d" => Self::Debug, + "release" | "--release" | "rel" | "r" => Self::Release, "release-smaller" | "--release-smaller" => Self::Custom("release-smaller"), profile => Self::Custom(Box::leak(profile.to_string().into_boxed_str())), } @@ -193,7 +194,7 @@ pub fn build_android(profile: BuildProfile, verbose: bool) -> Result<()> { Ok(()) } -pub fn run_android(verbose: bool) -> Result<()> { +pub fn run_android(profile: BuildProfile, verbose: bool) -> Result<()> { let sh = Shell::new()?; // check for adb @@ -205,23 +206,28 @@ pub fn run_android(verbose: bool) -> Result<()> { // change to android directory sh.change_dir("../android"); - // build the debug version - print_info("Building debug APK..."); + let (gradle_task, apk_path) = match profile { + BuildProfile::Release => ("assembleRelease", APK_PATH_RELEASE), + _ => ("assembleDebug", APK_PATH_DEBUG), + }; + + // build the APK + print_info(&format!("Building {} APK...", profile.target_dir_name())); if verbose { - cmd!(sh, "./gradlew assembleDebug").run().wrap_err("Failed to build APK")?; + cmd!(sh, "./gradlew {gradle_task}").run().wrap_err("Failed to build APK")?; } else { - cmd!(sh, "./gradlew assembleDebug").quiet().run().wrap_err("Failed to build APK")?; + cmd!(sh, "./gradlew {gradle_task}").quiet().run().wrap_err("Failed to build APK")?; } print_success("Build successful"); // install the APK print_info("Installing APK on device/emulator..."); - cmd!(sh, "adb install -r {APK_PATH}").run().wrap_err("Failed to install APK")?; + cmd!(sh, "adb install -r {apk_path}").run().wrap_err("Failed to install APK")?; print_success("App installed successfully"); // launch the app print_info("Launching app..."); - let full_activity = format!("{}{}", ANDROID_PACKAGE_NAME, ANDROID_ACTIVITY_NAME); + let full_activity = format!("{}/{}", ANDROID_PACKAGE_NAME, ANDROID_ACTIVITY_NAME); cmd!(sh, "adb shell am start -n {full_activity}").run().wrap_err("Failed to launch app")?; print_success("App launched successfully"); diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 3eacccb5d..91b74e910 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -48,7 +48,11 @@ enum Commands { /// Build and run Android app on device/emulator #[command(name = "run-android")] - RunAndroid, + RunAndroid { + /// Build profile: 'debug' or 'release' + #[arg(default_value = "debug")] + profile: String, + }, /// Build iOS library and generate Swift bindings #[command(name = "build-ios")] @@ -90,7 +94,10 @@ fn main() -> Result<()> { android::build_android(build_profile, cli.verbose) } - Commands::RunAndroid => android::run_android(cli.verbose), + Commands::RunAndroid { profile } => { + let build_profile = android::BuildProfile::from_str(&profile); + android::run_android(build_profile, cli.verbose) + } Commands::BuildIos { build_type, device, sign } => { let ios_build_type = ios::IosBuildType::from_str(&build_type); From 57672cabbf413ac2718e63ca842d5346439a4881 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 11:46:06 -0600 Subject: [PATCH 02/97] WIP --- android/app/build.gradle.kts | 6 +- android/app/src/main/AndroidManifest.xml | 1 + .../org/bitcoinppl/cove/CoinControlManager.kt | 14 +- .../java/org/bitcoinppl/cove/MainActivity.kt | 6 +- .../org/bitcoinppl/cove/QrCodeScanView.kt | 74 +++---- .../cove/SelectedWalletContainer.kt | 1 + .../org/bitcoinppl/cove/SendFlowContainer.kt | 10 + .../cove/navigation/CoveNavDisplay.kt | 203 ++++++++++++++++++ .../org/bitcoinppl/cove/send/SendScreen.kt | 73 ++++++- .../send/network_fee/NetworkFeeBottomSheet.kt | 2 +- .../SendConfirmationScreen.kt | 2 +- .../cove/sidebar/SidebarContainer.kt | 102 ++++++--- .../TransactionDetailsScreen.kt | 16 +- .../cove/utxo_list/UtxoListScreen.kt | 3 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 7 +- .../WalletTransactionsScreen.kt | 94 ++++++-- 16 files changed, 498 insertions(+), 116 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85fd24182..de875c893 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,7 +11,7 @@ android { defaultConfig { applicationId = "org.bitcoinppl.cove" - minSdk = 24 + minSdk = 33 targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -106,6 +106,10 @@ dependencies { // 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") + + // Navigation 3 for idiomatic Android navigation + implementation("androidx.navigation3:navigation3-runtime:1.0.0") + implementation("androidx.navigation3:navigation3-ui:1.0.0") } ktlint { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 18792cbf7..d209210d8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:name=".CoveApplication" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt index 0c90be463..93ee29605 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt @@ -120,15 +120,15 @@ class CoinControlManager( /** * called when user presses continue button - * applies selection to SendFlowManager + * navigates forward to CoinControlSetAmount screen with selected UTXOs */ - fun continuePressed() { - val sfm = AppManager.getInstance().sendFlowManager ?: return - updateSendFlowManagerTask?.cancel() - updateSendFlowManagerTask = null - + fun continuePressed(app: AppManager) { + val walletId = rust.id() val selectedUtxos = utxos.filter { selected.contains(it.outpoint) } - sfm.dispatch(SendFlowManagerAction.SetCoinControlMode(selectedUtxos)) + + // navigate forward to coin control set amount screen + val sendRoute = SendRoute.CoinControlSetAmount(walletId, selectedUtxos) + app.pushRoute(Route.Send(sendRoute)) } private fun updateSendFlowManager() { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 5a9fe87f6..33529021a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.foundation.isSystemInDarkTheme +import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer import org.bitcoinppl.cove.ui.theme.CoveTheme @@ -98,9 +99,10 @@ class MainActivity : ComponentActivity() { Box { LockView { SidebarContainer(app = app) { - // reset view hierarchy when network changes or route changes + // NavDisplay handles transitions and back gestures + // key resets view when network/routeId changes key(app.selectedNetwork, app.routeId) { - RouteView(app = app, route = app.router.currentRoute) + CoveNavDisplay(app = app) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index 9508a137a..5f3115a1b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -60,7 +60,7 @@ private sealed class QrCodeScannerState { ) : QrCodeScannerState() } -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalPermissionsApi::class) @Composable fun QrCodeScanView( onScanned: (StringOrData) -> Unit, @@ -71,47 +71,41 @@ fun QrCodeScanView( ) { val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - Scaffold( - topBar = { - if (showTopBar) { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = Color.White, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - ), + Box( + modifier = modifier.fillMaxSize().background(Color.Black) + ) { + when { + cameraPermissionState.status.isGranted -> { + QrScannerContent( + onScanned = onScanned, + onDismiss = onDismiss, + app = app, + modifier = Modifier.fillMaxSize(), ) } - }, - containerColor = Color.Black, - modifier = modifier.fillMaxSize(), - ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - when { - cameraPermissionState.status.isGranted -> { - QrScannerContent( - onScanned = onScanned, - onDismiss = onDismiss, - app = app, - modifier = Modifier.fillMaxSize(), - ) - } - else -> { - PermissionDeniedContent( - permissionState = cameraPermissionState, - onDismiss = onDismiss, - modifier = Modifier.fillMaxSize(), - ) - } + else -> { + PermissionDeniedContent( + permissionState = cameraPermissionState, + onDismiss = onDismiss, + modifier = Modifier.fillMaxSize(), + ) + } + } + + // overlaid back button + if (showTopBar) { + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(16.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt index db6127da9..e04264726 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt @@ -331,6 +331,7 @@ fun SelectedWalletContainer( // TODO: get from theme isDarkList = false, manager = wm, + app = app, snackbarHostState = snackbarHostState, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index 4464bf438..01e91d0b5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -166,6 +166,16 @@ private fun SendFlowRouteToScreen( onToggleBalanceVisibility = { walletManager.dispatch(WalletManagerAction.ToggleSensitiveVisibility) }, + onUnitChange = { unit -> + val bitcoinUnit = + when (unit.lowercase()) { + "sats" -> org.bitcoinppl.cove_core.types.BitcoinUnit.SAT + "btc" -> org.bitcoinppl.cove_core.types.BitcoinUnit.BTC + else -> org.bitcoinppl.cove_core.types.BitcoinUnit.SAT + } + walletManager.dispatch(WalletManagerAction.UpdateUnit(bitcoinUnit)) + }, + isFiatMode = walletManager.walletMetadata?.fiatOrBtc == FiatOrBtc.FIAT, isBalanceHidden = !(walletManager.walletMetadata?.sensitiveVisible ?: true), balanceAmount = walletManager.amountFmt(walletManager.balance.spendable()), balanceDenomination = walletManager.unit, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt new file mode 100644 index 000000000..7bde5a93b --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -0,0 +1,203 @@ +package org.bitcoinppl.cove.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import kotlinx.coroutines.delay +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.CoinControlContainer +import org.bitcoinppl.cove.NewWalletContainer +import org.bitcoinppl.cove.SelectedWalletContainer +import org.bitcoinppl.cove.SendFlowContainer +import org.bitcoinppl.cove.SettingsContainer +import org.bitcoinppl.cove.secret_words.SecretWordsScreen +import org.bitcoinppl.cove.ui.theme.MaterialMotion +import org.bitcoinppl.cove_core.Route + +/** + * Main navigation display using Navigation 3 + * Mirrors iOS NavigationStack pattern where FFI routes are the source of truth + */ +@Composable +fun CoveNavDisplay( + app: AppManager, + modifier: Modifier = Modifier, +) { + // initialize backStack synchronously with current routes (NOT empty!) + // NavDisplay requires at least one entry or it crashes + val initialRoutes = remember { + val routes = app.router.routes + if (routes.isEmpty()) listOf(app.router.default) else routes + } + val backStack = remember { + mutableStateListOf().apply { addAll(initialRoutes) } + } + + // sync back stack when FFI routes change + LaunchedEffect(app.router.routes, app.router.default) { + val ffiRoutes = app.router.routes + val currentBackStack = backStack.toList() + + // only update if different to avoid unnecessary recompositions + if (ffiRoutes != currentBackStack) { + backStack.clear() + if (ffiRoutes.isEmpty()) { + // if no routes, use default as the single route + backStack.add(app.router.default) + } else { + backStack.addAll(ffiRoutes) + } + } + } + + // handle hardware back button (only when there's more than one screen) + BackHandler(enabled = backStack.size > 1) { + app.popRoute() + } + + NavDisplay( + backStack = backStack, + onBack = { + // sync back to FFI when user navigates back + app.popRoute() + }, + modifier = modifier, + // Material Design Shared Axis X transitions (horizontal slide + fade) + transitionSpec = { forwardTransition() }, + popTransitionSpec = { backwardTransition() }, + predictivePopTransitionSpec = { backwardTransition() }, + entryProvider = { route -> + NavEntry(route) { + RouteContent(app = app, route = route) + } + }, + ) +} + +/** + * Shared Axis X transition for forward navigation (push) + * New screen slides in from right, old screen slides partially left + */ +private fun forwardTransition(): ContentTransform = + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedDecelerate), + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it / 3 }, + animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedAccelerate), + ) + +/** + * Shared Axis X transition for backward navigation (pop) + * Previous screen slides in from left, current screen slides out to right + */ +private fun backwardTransition(): ContentTransform = + slideInHorizontally( + initialOffsetX = { -it / 3 }, + animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedDecelerate), + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedAccelerate), + ) + +/** + * Maps FFI Route to screen content + * Same logic as RouteView but used within NavDisplay + */ +@Composable +private fun RouteContent(app: AppManager, route: Route) { + when (route) { + is Route.SelectedWallet -> { + SelectedWalletContainer( + app = app, + id = route.v1, + ) + } + + is Route.NewWallet -> { + NewWalletContainer( + app = app, + route = route.v1, + ) + } + + is Route.Settings -> { + SettingsContainer( + app = app, + route = route.v1, + ) + } + + is Route.SecretWords -> { + SecretWordsScreen(app = app, walletId = route.v1) + } + + is Route.TransactionDetails -> { + org.bitcoinppl.cove.transaction_details.TransactionDetailsContainer( + app = app, + walletId = route.id, + details = route.details, + ) + } + + is Route.Send -> { + SendFlowContainer( + app = app, + sendRoute = route.v1, + ) + } + + is Route.CoinControl -> { + CoinControlContainer( + app = app, + route = route.v1, + ) + } + + is Route.LoadAndReset -> { + LoadAndResetContent( + app = app, + nextRoutes = route.resetTo.map { it.route() }, + loadingTimeMs = route.afterMillis.toLong(), + ) + } + } +} + +/** + * Load and reset content - shows loading state then executes route reset + */ +@Composable +private fun LoadAndResetContent( + app: AppManager, + nextRoutes: List, + loadingTimeMs: Long, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + LaunchedEffect(Unit) { + delay(loadingTimeMs) + + if (nextRoutes.size > 1) { + app.resetRoute(nextRoutes) + } else if (nextRoutes.isNotEmpty()) { + app.resetRoute(nextRoutes[0]) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index 7245d77b6..b4934a78b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer 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.TextStyle @@ -76,6 +77,8 @@ fun SendScreen( onScanQr: () -> Unit, onChangeSpeed: () -> Unit, onToggleBalanceVisibility: () -> Unit = {}, + onUnitChange: (String) -> Unit = {}, + isFiatMode: Boolean = false, isBalanceHidden: Boolean = false, balanceAmount: String, balanceDenomination: String, @@ -130,11 +133,16 @@ fun SendScreen( .graphicsLayer(alpha = 0.25f), ) Column(modifier = Modifier.fillMaxSize()) { + val configuration = LocalConfiguration.current + val screenHeightDp = configuration.screenHeightDp.dp + val headerHeight = screenHeightDp * 0.145f + BalanceWidget( amount = balanceAmount, denomination = balanceDenomination, isHidden = isBalanceHidden, onToggleVisibility = onToggleBalanceVisibility, + height = headerHeight, ) Column( modifier = @@ -151,6 +159,8 @@ fun SendScreen( denomination = amountDenomination, dollarText = dollarEquivalentText, onAmountChanged = onAmountChanged, + onUnitChange = onUnitChange, + isFiatMode = isFiatMode, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) AddressWidget( @@ -193,12 +203,13 @@ private fun BalanceWidget( denomination: String, isHidden: Boolean, onToggleVisibility: () -> Unit, + height: androidx.compose.ui.unit.Dp = 160.dp, ) { Box( modifier = Modifier .fillMaxWidth() - .height(160.dp) + .height(height) .padding(horizontal = 16.dp, vertical = 20.dp), ) { Row( @@ -251,8 +262,11 @@ private fun AmountWidget( denomination: String, dollarText: String, onAmountChanged: (String) -> Unit, + onUnitChange: (String) -> Unit = {}, + isFiatMode: Boolean = false, ) { var amount by remember { mutableStateOf(initialAmount) } + var showUnitMenu by remember { mutableStateOf(false) } // bidirectional sync: update local state when parent state changes androidx.compose.runtime.LaunchedEffect(initialAmount) { @@ -299,15 +313,54 @@ private fun AmountWidget( ) } Spacer(Modifier.width(32.dp)) - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.offset(y = (-4).dp)) { - Text(denomination, color = MaterialTheme.colorScheme.onSurface, fontSize = 18.sp, maxLines = 1) - Spacer(Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) + Box { + Row( + verticalAlignment = Alignment.Bottom, + modifier = + Modifier + .offset(y = (-4).dp) + .then( + // only clickable when in BTC mode + if (!isFiatMode) { + Modifier.clickable { showUnitMenu = true } + } else { + Modifier + }, + ), + ) { + Text(denomination, color = MaterialTheme.colorScheme.onSurface, fontSize = 18.sp, maxLines = 1) + Spacer(Modifier.width(4.dp)) + if (!isFiatMode) { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + } + } + // dropdown menu for unit selection (only shown when in BTC mode) + if (!isFiatMode) { + DropdownMenu( + expanded = showUnitMenu, + onDismissRequest = { showUnitMenu = false }, + ) { + DropdownMenuItem( + text = { Text("sats") }, + onClick = { + onUnitChange("sats") + showUnitMenu = false + }, + ) + DropdownMenuItem( + text = { Text("btc") }, + onClick = { + onUnitChange("btc") + showUnitMenu = false + }, + ) + } + } } } Spacer(Modifier.height(8.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt index 7ad8d3b64..28c4e60b4 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/network_fee/NetworkFeeBottomSheet.kt @@ -409,7 +409,7 @@ fun CustomNetworkFeeBottomSheet( text = String.format(Locale.US, "%.2f", feeRate), color = MaterialTheme.colorScheme.onSurface, baseFontSize = 48.sp, - minFontSize = 24.sp, + minimumScaleFactor = 0.5f, fontWeight = FontWeight.Bold, ) Row( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt index f5519a884..b9001f9e0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt @@ -272,7 +272,7 @@ private fun AmountWidget( text = amount, color = MaterialTheme.colorScheme.onSurface, baseFontSize = 48.sp, - minFontSize = 24.sp, + minimumScaleFactor = 0.5f, fontWeight = FontWeight.Bold, modifier = Modifier.fillMaxWidth(), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index 0eaef9a5d..190873dd3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -1,19 +1,22 @@ package org.bitcoinppl.cove.sidebar -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,7 +38,7 @@ fun SidebarContainer( val density = LocalDensity.current val sidebarWidthPx = with(density) { SIDEBAR_WIDTH_DP.dp.toPx() } - var gestureOffset by remember { mutableStateOf(0f) } + var gestureOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } LaunchedEffect(app.isSidebarVisible) { @@ -47,22 +50,29 @@ fun SidebarContainer( // calculate target offset based on sidebar visibility val targetOffset = if (app.isSidebarVisible) sidebarWidthPx else 0f - // animate the offset when not dragging - use spring for natural feel - val animatedOffset by animateFloatAsState( - targetValue = targetOffset, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "sidebarOffset", - ) + // use Animatable for manual control over animation state + val animatedOffset = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + + // animate when visibility changes from non-drag sources (backdrop tap, menu button, etc.) + LaunchedEffect(app.isSidebarVisible) { + if (!isDragging) { + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) + } + } // current offset combines animated offset and gesture offset val currentOffset = if (isDragging) { (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) } else { - animatedOffset + animatedOffset.value } // calculate open percentage for backdrop opacity @@ -81,17 +91,33 @@ fun SidebarContainer( Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.45f * openPercentage)) + .pointerInput(sidebarWidthPx) { + detectTapGestures { offset -> + // only close if tap is outside the sidebar area (to the right of it) + if (offset.x > sidebarWidthPx) { + app.isSidebarVisible = false + } + } + } .pointerInput(Unit) { + var totalDrag = 0f detectHorizontalDragGestures( - onDragStart = { }, + onDragStart = { + totalDrag = 0f + }, onDragEnd = { - // tap to close - if (!isDragging) { + // only close if swipe distance exceeded minimum threshold + if (totalDrag < -20f) { app.isSidebarVisible = false } + totalDrag = 0f + }, + onDragCancel = { + totalDrag = 0f + }, + onHorizontalDrag = { _, dragAmount -> + totalDrag += dragAmount }, - onDragCancel = { }, - onHorizontalDrag = { _, _ -> }, ) }, ) @@ -118,24 +144,44 @@ fun SidebarContainer( }, onDragEnd = { if (isDragging) { - isDragging = false - - // determine if we should complete the open/close action val threshold = sidebarWidthPx * 0.3f val finalOffset = targetOffset + gestureOffset val shouldBeOpen = finalOffset > threshold + val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) - // only update if state actually changes - if (app.isSidebarVisible != shouldBeOpen) { - app.isSidebarVisible = shouldBeOpen - } - + isDragging = false gestureOffset = 0f + app.isSidebarVisible = shouldBeOpen + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) + } } }, onDragCancel = { - isDragging = false - gestureOffset = 0f + if (isDragging) { + val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) + isDragging = false + gestureOffset = 0f + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) + } + } }, onHorizontalDrag = { _, dragAmount -> if (isDragging) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 7cf3517e1..a3ea4e78c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -5,6 +5,11 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri +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.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image @@ -242,7 +247,7 @@ fun TransactionDetailsScreen( val txAmountSecondary by androidx.compose.runtime.produceState(initialValue = "---") { value = try { - "≈ ${transactionDetails.amountFiatFmt()}" + transactionDetails.amountFiatFmt() } catch (e: Exception) { "---" } @@ -345,8 +350,9 @@ fun TransactionDetailsScreen( txAmountPrimary, color = fg, baseFontSize = 36.sp, - minFontSize = 24.sp, + minimumScaleFactor = 0.67f, fontWeight = FontWeight.ExtraBold, + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(4.dp)) @@ -420,7 +426,11 @@ fun TransactionDetailsScreen( ) } - if (isExpanded) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { TransactionDetailsWidget( manager = manager, transactionDetails = transactionDetails, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt index e8d53eb95..d061352a6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt @@ -148,8 +148,7 @@ fun UtxoListScreen( ) }, onContinue = { - manager.continuePressed() - app.popRoute() + manager.continuePressed(app) }, onSearchChange = { query -> manager.updateSearch(query) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index fb80a9be7..4458b8bb0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -98,13 +98,13 @@ fun AutoSizeText( /** * Balance text that uses digit-based font size reduction - * Matches iOS implementation: base font reduces by 2sp per digit, minimum 20sp + * Matches iOS implementation: base font reduces by 2sp per digit * * @param text The balance text to display * @param modifier Modifier for the text * @param color Text color * @param baseFontSize Base font size (default 34.sp to match iOS) - * @param minFontSize Minimum font size (default 20.sp to match iOS) + * @param minimumScaleFactor Minimum scale factor (0.0 to 1.0), text shrinks to this percentage of baseFontSize * @param fontWeight Font weight * @param style Additional text style * @param onTextLayout Callback for text layout @@ -115,13 +115,14 @@ fun BalanceAutoSizeText( modifier: Modifier = Modifier, color: Color = Color.Unspecified, baseFontSize: TextUnit = 34.sp, - minFontSize: TextUnit = 20.sp, + minimumScaleFactor: Float = 0.5f, fontWeight: FontWeight? = null, style: TextStyle = LocalTextStyle.current, onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { val density = LocalDensity.current val fontFamilyResolver = LocalFontFamilyResolver.current + val minFontSize = baseFontSize * minimumScaleFactor // extract numeric value to count digits (matches iOS algorithm) val digits = remember(text) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 56f848347..1e9f2635a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -2,6 +2,8 @@ package org.bitcoinppl.cove.wallet_transactions import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -39,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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 @@ -52,6 +56,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletLoadState import org.bitcoinppl.cove.WalletManager @@ -59,6 +65,7 @@ import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.Transaction import org.bitcoinppl.cove_core.types.TransactionDirection import java.text.NumberFormat @@ -108,6 +115,7 @@ fun WalletTransactionsScreen( onMore: () -> Unit, isDarkList: Boolean, manager: WalletManager? = null, + app: AppManager? = null, initialBalanceHidden: Boolean = false, usdAmount: String = "$1,351.93", satsAmount: String = "1,166,369 SATS", @@ -139,24 +147,31 @@ fun WalletTransactionsScreen( val secondaryText = MaterialTheme.colorScheme.onSurfaceVariant val dividerColor = MaterialTheme.colorScheme.outlineVariant + // track scroll state to show wallet name in toolbar when scrolled + val listState = rememberLazyListState() + val isScrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + Scaffold( containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, + containerColor = if (isScrolled) CoveColor.midnightBlue else Color.Transparent, titleContentColor = Color.White, actionIconContentColor = Color.White, navigationIconContentColor = Color.White, ), title = { - Text( - actualWalletName, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (isScrolled) { + AutoSizeText( + text = actualWalletName, + maxFontSize = 16.sp, + minimumScaleFactor = 0.75f, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } }, navigationIcon = { if (canGoBack) { @@ -273,8 +288,8 @@ fun WalletTransactionsScreen( Text( text = stringResource(R.string.title_transactions), color = secondaryText, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, ) // render real transactions or show empty state @@ -296,6 +311,7 @@ fun WalletTransactionsScreen( } else { // render transactions dynamically in scrollable list LazyColumn( + state = listState, modifier = Modifier.weight(1f), ) { itemsIndexed(transactions) { index, txn -> @@ -324,6 +340,9 @@ fun WalletTransactionsScreen( listCard = listCard, primaryText = primaryText, secondaryText = secondaryText, + transaction = txn, + app = app, + manager = manager, ) } is Transaction.Unconfirmed -> { @@ -350,6 +369,9 @@ fun WalletTransactionsScreen( listCard = listCard, primaryText = primaryText, secondaryText = secondaryText, + transaction = txn, + app = app, + manager = manager, ) } } @@ -381,6 +403,9 @@ private fun TransactionWidget( listCard: Color, primaryText: Color, secondaryText: Color, + transaction: Transaction, + app: AppManager?, + manager: WalletManager?, ) { val title = stringResource( @@ -390,19 +415,51 @@ private fun TransactionWidget( }, ) + val scope = rememberCoroutineScope() + val isDark = isSystemInDarkTheme() + + // get transaction id for navigation + val txId = when (transaction) { + is Transaction.Confirmed -> transaction.v1.id() + is Transaction.Unconfirmed -> transaction.v1.id() + } + + // icon background color based on dark mode + val iconBackground = if (isDark) { + Color.Gray.copy(alpha = 0.35f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.75f) + } + Row( modifier = Modifier .fillMaxWidth() + .clickable { + // navigate to transaction details + if (app != null && manager != null) { + scope.launch { + try { + val details = manager.transactionDetails(txId) + val walletId = manager.walletMetadata?.id + if (walletId != null) { + app.pushRoute(Route.TransactionDetails(walletId, details)) + } + } catch (e: Exception) { + android.util.Log.e("TransactionWidget", "Failed to load transaction details", e) + } + } + } + } .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(listCard), + .size(50.dp) + .clip(RoundedCornerShape(6.dp)) + .background(iconBackground), contentAlignment = Alignment.Center, ) { Icon( @@ -422,13 +479,13 @@ private fun TransactionWidget( Text( text = title, color = primaryText, - fontSize = 16.sp, + fontSize = 15.sp, fontWeight = FontWeight.Medium, ) AutoSizeText( text = date, color = secondaryText, - maxFontSize = 14.sp, + maxFontSize = 12.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.Normal, ) @@ -438,7 +495,7 @@ private fun TransactionWidget( Text( text = amount, color = if (type == TransactionType.RECEIVED) CoveColor.TransactionReceived else primaryText, - fontSize = 20.sp, + fontSize = 17.sp, fontWeight = FontWeight.Normal, ) Text( @@ -463,18 +520,19 @@ private fun BalanceWidget( Text( text = if (isHidden) "$———" else usdAmount, color = Color.White.copy(alpha = 0.7f), - fontSize = 16.sp, + fontSize = 13.sp, ) Row(verticalAlignment = Alignment.CenterVertically) { BalanceAutoSizeText( text = if (isHidden) "•••••• SATS" else satsAmount, + modifier = Modifier.padding(end = 12.dp), color = Color.White, baseFontSize = 34.sp, - minFontSize = 20.sp, + minimumScaleFactor = 0.5f, fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), ) + Spacer(modifier = Modifier.weight(1f)) IconButton(onClick = { isHidden = !isHidden }) { Icon( imageVector = if (isHidden) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, From ada149b4948c487bd20abecbfa33598b0cf3fb34 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:07:01 -0600 Subject: [PATCH 03/97] Fix android side bar drag open --- .../cove/sidebar/SidebarContainer.kt | 143 +++++++++--------- 1 file changed, 74 insertions(+), 69 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index 190873dd3..1ff38c991 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -40,6 +40,7 @@ fun SidebarContainer( var gestureOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } + var isValidDrag by remember { mutableStateOf(false) } LaunchedEffect(app.isSidebarVisible) { if (app.isSidebarVisible) { @@ -69,7 +70,7 @@ fun SidebarContainer( // current offset combines animated offset and gesture offset val currentOffset = - if (isDragging) { + if (isDragging && isValidDrag) { (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) } else { animatedOffset.value @@ -78,8 +79,8 @@ fun SidebarContainer( // calculate open percentage for backdrop opacity val openPercentage = (currentOffset / sidebarWidthPx).coerceIn(0f, 1f) - // only enable gestures when at root (no routes) - val gesturesEnabled = app.rust.isAtRoot() + // only enable gestures when at root (no routes) - use reactive state + val gesturesEnabled = app.router.routes.isEmpty() Box( modifier = Modifier.fillMaxSize(), @@ -129,75 +130,79 @@ fun SidebarContainer( Modifier .fillMaxSize() .offset { IntOffset(currentOffset.roundToInt(), 0) } - .pointerInput(gesturesEnabled) { - if (gesturesEnabled) { - detectHorizontalDragGestures( - onDragStart = { offset -> - // only start drag from left edge when closed (using threshold constant) - if (!app.isSidebarVisible && offset.x < edgeThresholdPx) { - isDragging = true - gestureOffset = 0f - } else if (app.isSidebarVisible) { - isDragging = true - gestureOffset = 0f - } - }, - onDragEnd = { - if (isDragging) { - val threshold = sidebarWidthPx * 0.3f - val finalOffset = targetOffset + gestureOffset - val shouldBeOpen = finalOffset > threshold - val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) - - isDragging = false - gestureOffset = 0f - app.isSidebarVisible = shouldBeOpen - - scope.launch { - animatedOffset.snapTo(currentDragPosition) - animatedOffset.animateTo( - targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), - ) - } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + // check gesture enabled at drag start (reactive state) + if (!gesturesEnabled) return@detectHorizontalDragGestures + + isDragging = true + gestureOffset = 0f + + // determine if this is a valid drag that should affect the sidebar + isValidDrag = when { + app.isSidebarVisible -> true + offset.x < edgeThresholdPx -> true + else -> false + } + }, + onDragEnd = { + if (isDragging && isValidDrag) { + val threshold = sidebarWidthPx * 0.3f + val finalOffset = targetOffset + gestureOffset + val shouldBeOpen = finalOffset > threshold + val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) + + app.isSidebarVisible = shouldBeOpen + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) } - }, - onDragCancel = { - if (isDragging) { - val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) - isDragging = false - gestureOffset = 0f - - scope.launch { - animatedOffset.snapTo(currentDragPosition) - animatedOffset.animateTo( - targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), - ) - } + } + isDragging = false + isValidDrag = false + gestureOffset = 0f + }, + onDragCancel = { + if (isDragging && isValidDrag) { + val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) } - }, - onHorizontalDrag = { _, dragAmount -> - if (isDragging) { - gestureOffset += dragAmount - - // constrain gesture offset - val proposedOffset = targetOffset + gestureOffset - if (proposedOffset < 0f) { - gestureOffset = -targetOffset - } else if (proposedOffset > sidebarWidthPx) { - gestureOffset = sidebarWidthPx - targetOffset - } + } + isDragging = false + isValidDrag = false + gestureOffset = 0f + }, + onHorizontalDrag = { _, dragAmount -> + if (isDragging && isValidDrag) { + gestureOffset += dragAmount + + // constrain gesture offset + val proposedOffset = targetOffset + gestureOffset + if (proposedOffset < 0f) { + gestureOffset = -targetOffset + } else if (proposedOffset > sidebarWidthPx) { + gestureOffset = sidebarWidthPx - targetOffset } - }, - ) - } + } + }, + ) }, ) { content() From a83d1903f3e29f3dad09f3df41befeb2fdf69692 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:07:17 -0600 Subject: [PATCH 04/97] Fix android side bar drag open --- .../java/org/bitcoinppl/cove/sidebar/SidebarView.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 9708814ef..b9d512bb0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,8 +39,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor @@ -127,9 +124,7 @@ fun SidebarView( WalletItem( wallet = wallet, onClick = { - app.isSidebarVisible = false - coroutineScope.launch { - delay(300) + app.closeSidebarAndNavigate { app.rust.selectWallet(wallet.id) } }, @@ -186,9 +181,7 @@ fun SidebarView( Modifier .fillMaxWidth() .clickable { - app.isSidebarVisible = false - coroutineScope.launch { - delay(300) + app.closeSidebarAndNavigate { app.pushRoute(Route.Settings(SettingsRoute.Main)) } }.padding(vertical = 8.dp), From 53ea223b6a954c258844725f25aa582c77fed2a9 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:07:35 -0600 Subject: [PATCH 05/97] Fix TransactionsDetailScreen android background colors --- .../TransactionDetailsScreen.kt | 63 +++++++------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index a3ea4e78c..e87b9c975 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -189,9 +189,9 @@ fun TransactionDetailsScreen( val snackbarHostState = remember { SnackbarHostState() } // theme colors - val bg = if (isDark) Color(0xFF000000) else Color(0xFFFFFFFF) - val fg = if (isDark) Color(0xFFEFEFEF) else Color(0xFF101010) - val sub = if (isDark) Color(0xFFB8B8B8) else Color(0xFF8F8F95) + val bg = MaterialTheme.colorScheme.background + val fg = MaterialTheme.colorScheme.onBackground + val sub = MaterialTheme.colorScheme.onSurfaceVariant val checkCircle = if (isDark) Color(0xFF0F0F12) else Color(0xFF0F1012) val chipBg = CoveColor.TransactionReceived @@ -391,8 +391,8 @@ fun TransactionDetailsScreen( shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color.Black, - contentColor = Color.White, + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, ), ) { Row( @@ -417,7 +417,7 @@ fun TransactionDetailsScreen( Modifier .fillMaxWidth() .height(1.dp) - .background(if (isDark) Color(0xFF222428) else Color(0xFFE4E5E7)), + .background(MaterialTheme.colorScheme.outlineVariant), ) Spacer(Modifier.height(24.dp)) ConfirmationIndicatorView( @@ -435,7 +435,6 @@ fun TransactionDetailsScreen( manager = manager, transactionDetails = transactionDetails, numberOfConfirmations = numberOfConfirmations, - isDark = isDark, feeFiatFmt = feeFiatFmt, sentSansFeeFiatFmt = sentSansFeeFiatFmt, totalSpentFiatFmt = totalSpentFiatFmt, @@ -494,15 +493,14 @@ private fun TransactionDetailsWidget( manager: WalletManager, transactionDetails: TransactionDetails, numberOfConfirmations: Int?, - isDark: Boolean, feeFiatFmt: String, sentSansFeeFiatFmt: String, totalSpentFiatFmt: String, metadata: WalletMetadata?, ) { - val dividerColor = if (isDark) Color(0xFF222428) else Color(0xFFE4E5E7) - val sub = if (isDark) Color(0xFFB8B8B8) else Color(0xFF8F8F95) - val fg = if (isDark) Color(0xFFEFEFEF) else Color(0xFF101010) + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val sub = MaterialTheme.colorScheme.onSurfaceVariant + val fg = MaterialTheme.colorScheme.onBackground val isSent = transactionDetails.isSent() val isConfirmed = transactionDetails.isConfirmed() @@ -521,7 +519,7 @@ private fun TransactionDetailsWidget( Column(modifier = Modifier.fillMaxWidth()) { Text( stringResource(R.string.label_confirmations), - color = if (isDark) Color(0xFFB8B8B8) else Color(0xFF6F6F75), + color = sub, fontSize = 14.sp, ) Spacer(Modifier.height(8.dp)) @@ -537,7 +535,7 @@ private fun TransactionDetailsWidget( Text( stringResource(R.string.label_block_number), - color = if (isDark) Color(0xFFB8B8B8) else Color(0xFF6F6F75), + color = sub, fontSize = 14.sp, ) Spacer(Modifier.height(8.dp)) @@ -567,7 +565,7 @@ private fun TransactionDetailsWidget( Column(modifier = Modifier.fillMaxWidth()) { Text( addressLabel, - color = if (isDark) Color(0xFFB8B8B8) else Color(0xFF6F6F75), + color = sub, fontSize = 16.sp, ) Spacer(Modifier.height(8.dp)) @@ -631,7 +629,6 @@ private fun TransactionDetailsWidget( label = stringResource(R.string.label_network_fee), primary = transactionDetails.feeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), secondary = "≈ $feeFiatFmt", - isDark = isDark, showInfoIcon = true, onInfoClick = { /* TODO: show fee info */ }, ) @@ -641,7 +638,6 @@ private fun TransactionDetailsWidget( label = stringResource(R.string.label_recipient_receives), primary = transactionDetails.sentSansFeeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), secondary = "≈ $sentSansFeeFiatFmt", - isDark = isDark, ) Spacer(Modifier.height(24.dp)) @@ -658,7 +654,6 @@ private fun TransactionDetailsWidget( label = stringResource(R.string.label_total_spent), primary = transactionDetails.amountFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), secondary = "≈ $totalSpentFiatFmt", - isDark = isDark, isTotal = true, ) } else { @@ -666,9 +661,6 @@ private fun TransactionDetailsWidget( ReceivedTransactionDetails( transactionDetails = transactionDetails, numberOfConfirmations = numberOfConfirmations, - isDark = isDark, - sub = sub, - fg = fg, ) } @@ -680,28 +672,16 @@ private fun DetailsWidget( label: String, primary: String?, secondary: String?, - isDark: Boolean, isTotal: Boolean = false, showInfoIcon: Boolean = false, onInfoClick: () -> Unit = {}, ) { if (primary == null) return - val sub = if (isDark) Color(0xFF8F8F95) else Color(0xFF6F6F75) - val fg = if (isDark) Color(0xFFEFEFEF) else Color(0xFF101010) + val sub = MaterialTheme.colorScheme.onSurfaceVariant + val fg = MaterialTheme.colorScheme.onBackground - val labelColor = - if (isTotal) { - if (isDark) Color(0xFFEFEFEF) else Color(0xFF101010) - } else { - if (isDark) Color(0xFFB8B8B8) else Color(0xFF9CA3AF) - } - - val primaryColor = - if (isTotal) { - fg - } else { - if (isDark) Color(0xFFB8B8B8) else Color(0xFF9CA3AF) - } + val labelColor = if (isTotal) fg else sub + val primaryColor = if (isTotal) fg else sub Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Row( @@ -722,7 +702,7 @@ private fun DetailsWidget( Icon( imageVector = Icons.Outlined.Info, contentDescription = null, - tint = if (isDark) Color(0xFFB8B8B8) else Color(0xFF9CA3AF), + tint = sub, modifier = Modifier.size(16.dp), ) }, @@ -743,12 +723,11 @@ private fun DetailsWidget( private fun ReceivedTransactionDetails( transactionDetails: TransactionDetails, numberOfConfirmations: Int?, - isDark: Boolean, - sub: Color, - fg: Color, ) { val context = LocalContext.current var isCopied by remember { mutableStateOf(false) } + val sub = MaterialTheme.colorScheme.onSurfaceVariant + val fg = MaterialTheme.colorScheme.onBackground Column(modifier = Modifier.fillMaxWidth()) { // received at address with copy button @@ -759,7 +738,7 @@ private fun ReceivedTransactionDetails( Column(modifier = Modifier.weight(1f)) { Text( stringResource(R.string.label_received_at), - color = if (isDark) Color(0xFFB8B8B8) else Color(0xFF6F6F75), + color = sub, fontSize = 16.sp, ) Spacer(Modifier.height(8.dp)) @@ -783,7 +762,7 @@ private fun ReceivedTransactionDetails( isCopied = true }, shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, if (isDark) Color(0xFF4A4A4A) else Color(0xFFD1D1D6)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), colors = ButtonDefaults.outlinedButtonColors( contentColor = fg, From f61c45e1ccb1a07aa83f05fbb8fdd5c22bd62211 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:07:50 -0600 Subject: [PATCH 06/97] Fix android sidebar buttons select --- .../java/org/bitcoinppl/cove/AppManager.kt | 8 +++++ .../cove/navigation/CoveNavDisplay.kt | 36 +++++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) 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 dc4d6315d..8d71e099d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -233,6 +233,14 @@ class AppManager private constructor() : FfiReconcile { wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() } } + fun closeSidebarAndNavigate(action: suspend () -> Unit) { + isSidebarVisible = false + mainScope.launch { + kotlinx.coroutines.delay(300) + action() + } + } + fun pushRoute(route: Route) { logDebug("pushRoute: $route") isSidebarVisible = false diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index 7bde5a93b..170f0a69f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -3,6 +3,8 @@ package org.bitcoinppl.cove.navigation import androidx.activity.compose.BackHandler import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -64,9 +66,13 @@ fun CoveNavDisplay( } } - // handle hardware back button (only when there's more than one screen) - BackHandler(enabled = backStack.size > 1) { - app.popRoute() + // handle hardware back button + BackHandler(enabled = true) { + if (app.rust.canGoBack()) { + app.popRoute() + } else { + app.toggleSidebar() + } } NavDisplay( @@ -89,29 +95,37 @@ fun CoveNavDisplay( } /** - * Shared Axis X transition for forward navigation (push) - * New screen slides in from right, old screen slides partially left + * Material SharedAxis X transition for forward navigation (push) + * Per spec: outgoing fades 0-100ms, incoming fades 100-300ms, both slide over 300ms */ private fun forwardTransition(): ContentTransform = slideInHorizontally( initialOffsetX = { it }, - animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedDecelerate), + animationSpec = tween(300, easing = MaterialMotion.emphasizedDecelerate), + ) + fadeIn( + animationSpec = tween(200, delayMillis = 100), ) togetherWith slideOutHorizontally( targetOffsetX = { -it / 3 }, - animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedAccelerate), + animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), + ) + fadeOut( + animationSpec = tween(100), ) /** - * Shared Axis X transition for backward navigation (pop) - * Previous screen slides in from left, current screen slides out to right + * Material SharedAxis X transition for backward navigation (pop) + * Per spec: outgoing fades 0-100ms, incoming fades 100-300ms, both slide over 300ms */ private fun backwardTransition(): ContentTransform = slideInHorizontally( initialOffsetX = { -it / 3 }, - animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedDecelerate), + animationSpec = tween(300, easing = MaterialMotion.emphasizedDecelerate), + ) + fadeIn( + animationSpec = tween(200, delayMillis = 100), ) togetherWith slideOutHorizontally( targetOffsetX = { it }, - animationSpec = tween(MaterialMotion.DURATION_MEDIUM_2, easing = MaterialMotion.emphasizedAccelerate), + animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), + ) + fadeOut( + animationSpec = tween(100), ) /** From 71406f65e73c8a7a79ed2abc5633a0843b62d882 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:20:05 -0600 Subject: [PATCH 07/97] WIP --- .../cove/navigation/CoveNavDisplay.kt | 10 +- .../TransactionDetailsScreen.kt | 276 ++++++++++++------ .../org/bitcoinppl/cove/utils/FfiColorExt.kt | 44 +++ 3 files changed, 236 insertions(+), 94 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index 170f0a69f..ebbc1825e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -66,13 +66,9 @@ fun CoveNavDisplay( } } - // handle hardware back button - BackHandler(enabled = true) { - if (app.rust.canGoBack()) { - app.popRoute() - } else { - app.toggleSidebar() - } + // handle hardware back button (only when navigation is possible) + BackHandler(enabled = app.rust.canGoBack()) { + app.popRoute() } NavDisplay( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index e87b9c975..34e3bc128 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,6 +34,7 @@ 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.AccessTime import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.NorthEast import androidx.compose.material.icons.filled.SouthWest @@ -64,6 +67,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -79,13 +83,18 @@ 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.utils.toColor import org.bitcoinppl.cove.views.AutoSizeText 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.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletMetadata import org.bitcoinppl.cove_core.types.BitcoinUnit +import org.bitcoinppl.cove_core.types.FfiColorScheme +import org.bitcoinppl.cove_core.types.TransactionDirection import kotlin.math.min private const val INITIAL_DELAY_MS = 2000L @@ -192,29 +201,33 @@ fun TransactionDetailsScreen( val bg = MaterialTheme.colorScheme.background val fg = MaterialTheme.colorScheme.onBackground val sub = MaterialTheme.colorScheme.onSurfaceVariant - val checkCircle = if (isDark) Color(0xFF0F0F12) else Color(0xFF0F1012) val chipBg = CoveColor.TransactionReceived - val ringColors: List = - if (isDark) { - listOf( - Color.White.copy(alpha = 0.60f), - Color.White.copy(alpha = 0.35f), - Color.White.copy(alpha = 0.18f), - ) - } else { - listOf( - Color.Black.copy(alpha = 0.50f), - Color.Black.copy(alpha = 0.30f), - Color.Black.copy(alpha = 0.15f), - ) - } - // 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() + + val ringColors: List = listOf( + presenter.ringColor(txState, colorScheme, direction, confirmationCount, 1L).toColor() + .copy(alpha = if (isDark) 0.88f else 0.44f), + presenter.ringColor(txState, colorScheme, direction, confirmationCount, 2L).toColor() + .copy(alpha = if (isDark) 0.66f else 0.24f), + presenter.ringColor(txState, colorScheme, direction, confirmationCount, 3L).toColor() + .copy(alpha = if (isDark) 0.33f else 0.10f), + ) + val headerTitle = stringResource( id = @@ -295,7 +308,7 @@ fun TransactionDetailsScreen( .fillMaxHeight() .align(Alignment.TopCenter) .offset(y = (-60).dp) - .graphicsLayer(alpha = if (isDark) 0.75f else 0.15f), + .graphicsLayer(alpha = if (isDark) 1.0f else 0.40f), ) Column( @@ -310,9 +323,10 @@ fun TransactionDetailsScreen( CheckWithRingsWidget( diameter = 180.dp, - circleColor = checkCircle, + circleColor = circleColor, ringColors = ringColors, - checkColor = Color.White, + iconColor = iconColor, + isConfirmed = isConfirmed, ) Spacer(Modifier.height(16.dp)) @@ -320,9 +334,9 @@ fun TransactionDetailsScreen( Text( headerTitle, color = fg, - fontSize = 32.sp, + fontSize = 28.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 36.sp, + lineHeight = 32.sp, ) Spacer(Modifier.height(4.dp)) @@ -336,13 +350,49 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(24.dp)) - Text( - message, - color = sub, - fontSize = 16.sp, - textAlign = TextAlign.Center, - lineHeight = 22.sp, - ) + // 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 = 16.sp, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + ) + Text( + text = formattedDate, + color = sub, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + ) + } else if (!isConfirmed) { + Text( + text = "Your transaction is pending.", + color = sub, + fontSize = 16.sp, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + ) + Text( + text = "Please check back soon for an update.", + color = sub, + fontSize = 16.sp, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + ) + } + } Spacer(Modifier.height(32.dp)) @@ -351,7 +401,7 @@ fun TransactionDetailsScreen( color = fg, baseFontSize = 36.sp, minimumScaleFactor = 0.67f, - fontWeight = FontWeight.ExtraBold, + fontWeight = FontWeight.Bold, modifier = Modifier.fillMaxWidth(), ) @@ -366,48 +416,40 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(32.dp)) - if (isDark) { - OutlinedButton( - onClick = {}, - shape = RoundedCornerShape(24.dp), - border = BorderStroke(1.dp, fg), - colors = ButtonDefaults.outlinedButtonColors(contentColor = fg), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(actionIcon, contentDescription = null) - Spacer(Modifier.size(8.dp)) - Text( - text = stringResource(actionLabelRes), - fontWeight = FontWeight.Normal, - fontSize = 16.sp, + // transaction status capsule matching iOS styling + val coolGray = Color(0xFF787880) + val capsuleConfig = + when { + isReceived && isConfirmed -> { + Triple( + Color(0xFF34C759).copy(alpha = 0.2f), // green with 20% opacity + Color(0xFF34C759), // green text + false, ) } - } - } else { - Button( - onClick = {}, - shape = RoundedCornerShape(24.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.inverseSurface, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(actionIcon, contentDescription = null) - Spacer(Modifier.size(8.dp)) - Text( - text = stringResource(actionLabelRes), - fontWeight = FontWeight.Normal, - fontSize = 16.sp, + isSent && isConfirmed -> { + Triple( + Color.Black, // black background + Color.White, // white text + true, + ) // white stroke + } + else -> { + Triple( + coolGray, // coolGray background for pending + Color.Black.copy(alpha = 0.8f), // black text at 80% opacity + false, ) } } - } + + TransactionCapsule( + text = stringResource(actionLabelRes), + icon = actionIcon, + backgroundColor = capsuleConfig.first, + textColor = capsuleConfig.second, + showStroke = capsuleConfig.third, + ) // show confirmation indicator if < 3 confirmations if (numberOfConfirmations != null && numberOfConfirmations!! < 3) { @@ -476,10 +518,10 @@ fun TransactionDetailsScreen( ) { Text( text = stringResource(if (isExpanded) R.string.btn_hide_details else R.string.btn_show_details), - color = sub, - fontSize = 14.sp, + color = sub.copy(alpha = 0.8f), + fontSize = 13.sp, textAlign = TextAlign.Center, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, ) } } @@ -820,12 +862,61 @@ private fun ReceivedTransactionDetails( } } +@Composable +private fun TransactionCapsule( + text: String, + icon: ImageVector, + backgroundColor: Color, + textColor: Color, + showStroke: Boolean = false, +) { + Box( + modifier = + Modifier + .width(130.dp) + .height(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background(backgroundColor) + .then( + if (showStroke) { + Modifier.border( + width = 1.dp, + color = Color.White, + shape = RoundedCornerShape(15.dp), + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(12.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = text, + color = textColor, + fontSize = 14.sp, + ) + } + } +} + @Composable private fun CheckWithRingsWidget( diameter: Dp, circleColor: Color, ringColors: List, - checkColor: Color, + iconColor: Color, + isConfirmed: Boolean, ) { Box( contentAlignment = Alignment.Center, @@ -862,23 +953,34 @@ private fun CheckWithRingsWidget( .background(circleColor), contentAlignment = Alignment.Center, ) { - Canvas(modifier = Modifier.size(diameter * 0.36f)) { - val stroke = 3.dp.toPx() - val w = size.width - val h = size.height - drawLine( - color = checkColor, - start = Offset(w * 0.1f, h * 0.55f), - end = Offset(w * 0.4f, h * 0.85f), - strokeWidth = stroke, - cap = StrokeCap.Round, - ) - drawLine( - color = checkColor, - start = Offset(w * 0.4f, h * 0.85f), - end = Offset(w * 0.9f, h * 0.15f), - strokeWidth = stroke, - cap = StrokeCap.Round, + if (isConfirmed) { + // draw checkmark with canvas for confirmed transactions + Canvas(modifier = Modifier.size(diameter * 0.36f)) { + val stroke = 3.dp.toPx() + val w = size.width + val h = size.height + drawLine( + color = iconColor, + start = Offset(w * 0.1f, h * 0.55f), + end = Offset(w * 0.4f, h * 0.85f), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + color = iconColor, + start = Offset(w * 0.4f, h * 0.85f), + end = Offset(w * 0.9f, h * 0.15f), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } + } else { + // show clock icon for pending transactions + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(diameter * 0.36f), ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt new file mode 100644 index 000000000..722524bfb --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt @@ -0,0 +1,44 @@ +package org.bitcoinppl.cove.utils + +import androidx.compose.ui.graphics.Color +import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove_core.types.FfiColor +import org.bitcoinppl.cove_core.types.FfiOpacity + +/** + * Convert FfiColor from Rust to Android Compose Color + * Matches iOS implementation in WalletColor+Ext.swift + */ +fun FfiColor.toColor(): Color { + return when (this) { + is FfiColor.Red -> Color.Red.withOpacity(v1) + is FfiColor.Blue -> Color.Blue.withOpacity(v1) + is FfiColor.Green -> Color.Green.withOpacity(v1) + is FfiColor.Yellow -> Color.Yellow.withOpacity(v1) + is FfiColor.Orange -> Color(0xFFFF9500).withOpacity(v1) + is FfiColor.Purple -> Color(0xFFAF52DE).withOpacity(v1) + is FfiColor.Pink -> Color(0xFFFF2D55).withOpacity(v1) + is FfiColor.White -> Color.White.withOpacity(v1) + is FfiColor.Black -> Color.Black.withOpacity(v1) + is FfiColor.Gray -> Color.Gray.withOpacity(v1) + is FfiColor.CoolGray -> CoveColor.almostGray.withOpacity(v1) + is FfiColor.Custom -> { + Color( + red = v1.r.toFloat() / 255f, + green = v1.g.toFloat() / 255f, + blue = v1.b.toFloat() / 255f, + ).withOpacity(v2) + } + } +} + +/** + * Apply FfiOpacity (0-100) to a Color + * FfiOpacity is a percentage value where 100 = fully opaque + */ +fun Color.withOpacity(opacity: FfiOpacity): Color { + if (opacity == 100.toUByte()) { + return this + } + return this.copy(alpha = opacity.toFloat() / 100f) +} From bdf8f9bcbd4acab9c3db9272846137594a8f4821 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 25 Nov 2025 14:53:47 -0600 Subject: [PATCH 08/97] Fix back button and side bar interaction on android --- android/app/build.gradle.kts | 2 +- .../cove/sidebar/SidebarContainer.kt | 151 +++++++++--------- .../TransactionDetailsScreen.kt | 17 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 2 + 4 files changed, 91 insertions(+), 81 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index de875c893..b288db153 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,7 +65,7 @@ dependencies { implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3") - implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.activity:activity-compose:1.12.0") implementation(platform("androidx.compose:compose-bom:2025.08.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index 1ff38c991..4d0c4861c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -130,80 +130,83 @@ fun SidebarContainer( Modifier .fillMaxSize() .offset { IntOffset(currentOffset.roundToInt(), 0) } - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { offset -> - // check gesture enabled at drag start (reactive state) - if (!gesturesEnabled) return@detectHorizontalDragGestures - - isDragging = true - gestureOffset = 0f - - // determine if this is a valid drag that should affect the sidebar - isValidDrag = when { - app.isSidebarVisible -> true - offset.x < edgeThresholdPx -> true - else -> false - } - }, - onDragEnd = { - if (isDragging && isValidDrag) { - val threshold = sidebarWidthPx * 0.3f - val finalOffset = targetOffset + gestureOffset - val shouldBeOpen = finalOffset > threshold - val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) - - app.isSidebarVisible = shouldBeOpen - - scope.launch { - animatedOffset.snapTo(currentDragPosition) - animatedOffset.animateTo( - targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), - ) - } - } - isDragging = false - isValidDrag = false - gestureOffset = 0f - }, - onDragCancel = { - if (isDragging && isValidDrag) { - val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) - - scope.launch { - animatedOffset.snapTo(currentDragPosition) - animatedOffset.animateTo( - targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), - ) - } - } - isDragging = false - isValidDrag = false - gestureOffset = 0f - }, - onHorizontalDrag = { _, dragAmount -> - if (isDragging && isValidDrag) { - gestureOffset += dragAmount - - // constrain gesture offset - val proposedOffset = targetOffset + gestureOffset - if (proposedOffset < 0f) { - gestureOffset = -targetOffset - } else if (proposedOffset > sidebarWidthPx) { - gestureOffset = sidebarWidthPx - targetOffset - } - } - }, - ) - }, + .then( + if (gesturesEnabled) { + Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + isDragging = true + gestureOffset = 0f + + // determine if this is a valid drag that should affect the sidebar + isValidDrag = when { + app.isSidebarVisible -> true + offset.x < edgeThresholdPx -> true + else -> false + } + }, + onDragEnd = { + if (isDragging && isValidDrag) { + val threshold = sidebarWidthPx * 0.3f + val finalOffset = targetOffset + gestureOffset + val shouldBeOpen = finalOffset > threshold + val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) + + app.isSidebarVisible = shouldBeOpen + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) + } + } + isDragging = false + isValidDrag = false + gestureOffset = 0f + }, + onDragCancel = { + if (isDragging && isValidDrag) { + val currentDragPosition = (targetOffset + gestureOffset).coerceIn(0f, sidebarWidthPx) + + scope.launch { + animatedOffset.snapTo(currentDragPosition) + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), + ) + } + } + isDragging = false + isValidDrag = false + gestureOffset = 0f + }, + onHorizontalDrag = { _, dragAmount -> + if (isDragging && isValidDrag) { + gestureOffset += dragAmount + + // constrain gesture offset + val proposedOffset = targetOffset + gestureOffset + if (proposedOffset < 0f) { + gestureOffset = -targetOffset + } else if (proposedOffset > sidebarWidthPx) { + gestureOffset = sidebarWidthPx - targetOffset + } + } + }, + ) + } + } else { + Modifier + } + ), ) { content() } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 34e3bc128..32b85cd00 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.runtime.remember 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.geometry.Offset import androidx.compose.ui.graphics.Color @@ -219,13 +220,16 @@ fun TransactionDetailsScreen( val circleColor = presenter.backgroundColor(txState, direction, colorScheme, confirmationCount).toColor() val iconColor = presenter.iconColor(txState, direction, colorScheme, confirmationCount).toColor() + // get ring colors from presenter - apply opacity by multiplying with existing alpha + // iOS applies .opacity() which multiplies, so we do the same here + 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() - .copy(alpha = if (isDark) 0.88f else 0.44f), + .let { it.copy(alpha = it.alpha * ringOpacities[0]) }, presenter.ringColor(txState, colorScheme, direction, confirmationCount, 2L).toColor() - .copy(alpha = if (isDark) 0.66f else 0.24f), + .let { it.copy(alpha = it.alpha * ringOpacities[1]) }, presenter.ringColor(txState, colorScheme, direction, confirmationCount, 3L).toColor() - .copy(alpha = if (isDark) 0.33f else 0.10f), + .let { it.copy(alpha = it.alpha * ringOpacities[2]) }, ) val headerTitle = @@ -308,7 +312,7 @@ fun TransactionDetailsScreen( .fillMaxHeight() .align(Alignment.TopCenter) .offset(y = (-60).dp) - .graphicsLayer(alpha = if (isDark) 1.0f else 0.40f), + .alpha(if (isDark) 1.0f else 0.40f), ) Column( @@ -402,6 +406,7 @@ fun TransactionDetailsScreen( baseFontSize = 36.sp, minimumScaleFactor = 0.67f, fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) @@ -500,8 +505,8 @@ fun TransactionDetailsScreen( }, colors = ButtonDefaults.buttonColors( - containerColor = if (isDark) MaterialTheme.colorScheme.surfaceVariant else CoveColor.midnightBlue, - contentColor = if (isDark) MaterialTheme.colorScheme.outline else Color.White, + containerColor = if (isDark) CoveColor.midnightBtnDark else CoveColor.midnightBlue, + contentColor = Color.White, ), modifier = Modifier diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 4458b8bb0..13b0b6d49 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -117,6 +117,7 @@ fun BalanceAutoSizeText( baseFontSize: TextUnit = 34.sp, minimumScaleFactor: Float = 0.5f, fontWeight: FontWeight? = null, + textAlign: TextAlign? = null, style: TextStyle = LocalTextStyle.current, onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { @@ -166,6 +167,7 @@ fun BalanceAutoSizeText( color = color, fontSize = finalFontSize, fontWeight = fontWeight, + textAlign = textAlign, style = style, maxLines = 1, softWrap = false, From 4498d2fb449cde9af4569470fb24fbec851b8cda Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 08:06:03 -0600 Subject: [PATCH 09/97] Styling fixes and fix `loadAndReset` route on android --- .../cove/navigation/CoveNavDisplay.kt | 7 +- .../cove/sheets/FeeRateSelectorSheet.kt | 5 +- .../TransactionDetailsScreen.kt | 321 +++++++++++++----- .../TransactionLabelView.kt | 6 +- .../org/bitcoinppl/cove/ui/theme/Color.kt | 7 +- .../org/bitcoinppl/cove/ui/theme/Theme.kt | 9 + .../org/bitcoinppl/cove/utils/FfiColorExt.kt | 21 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 30 +- .../ReceiveAddressSheet.kt | 5 +- .../WalletTransactionsScreen.kt | 38 ++- android/app/src/main/res/values/strings.xml | 2 + 11 files changed, 314 insertions(+), 137 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index ebbc1825e..41ac8017f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -203,11 +203,6 @@ private fun LoadAndResetContent( LaunchedEffect(Unit) { delay(loadingTimeMs) - - if (nextRoutes.size > 1) { - app.resetRoute(nextRoutes) - } else if (nextRoutes.isNotEmpty()) { - app.resetRoute(nextRoutes[0]) - } + app.rust.resetAfterLoading(nextRoutes) } } 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 7d2e9c6d2..14c183713 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 @@ -3,7 +3,6 @@ package org.bitcoinppl.cove.sheets import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,6 +20,7 @@ import org.bitcoinppl.cove.SendFlowManager import org.bitcoinppl.cove.SendFlowPresenter import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove_core.types.FeeRateOptionWithTotalFee import org.bitcoinppl.cove_core.types.FeeRateOptionsWithTotalFee import org.bitcoinppl.cove_core.types.FeeSpeed @@ -128,7 +128,6 @@ fun FeeRateSelectorSheet( Spacer(modifier = Modifier.height(20.dp)) // customize fee button - val isDark = isSystemInDarkTheme() Button( onClick = { showCustomFeeSheet = true }, modifier = @@ -179,8 +178,6 @@ private fun FeeOptionCard( isSelected: Boolean, onSelect: () -> Unit, ) { - val isDark = isSystemInDarkTheme() - val backgroundColor = if (isSelected) { CoveColor.midnightBlue.copy(alpha = 0.8f) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 32b85cd00..6ade0f292 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -29,6 +29,7 @@ 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.ScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -70,11 +71,14 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode 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.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -84,6 +88,7 @@ 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.isLight import org.bitcoinppl.cove.utils.toColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText @@ -93,9 +98,14 @@ import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletMetadata +import org.bitcoinppl.cove_core.NoHandle as CoreNoHandle +import org.bitcoinppl.cove_core.types.NoHandle import org.bitcoinppl.cove_core.types.BitcoinUnit import org.bitcoinppl.cove_core.types.FfiColorScheme import org.bitcoinppl.cove_core.types.TransactionDirection +import org.bitcoinppl.cove_core.types.Address +import org.bitcoinppl.cove_core.types.Amount +import org.bitcoinppl.cove_core.types.TxId import kotlin.math.min private const val INITIAL_DELAY_MS = 2000L @@ -104,6 +114,101 @@ private const val NORMAL_POLL_INTERVAL_MS = 60000L private const val MAX_POLL_ERRORS = 10 private const val CONFIRMATIONS_THRESHOLD = 3 +@Preview(name = "Confirmed Received", showBackground = true) +@Composable +private fun TransactionDetailsConfirmedReceivedPreview() { + TransactionDetailsPreviewContent(previewDetails(isSent = false, isConfirmed = true)) +} + +@Preview(name = "Confirmed Sent", showBackground = true) +@Composable +private fun TransactionDetailsConfirmedSentPreview() { + TransactionDetailsPreviewContent(previewDetails(isSent = true, isConfirmed = true)) +} + +@Preview(name = "Pending Received", showBackground = true) +@Composable +private fun TransactionDetailsPendingReceivedPreview() { + TransactionDetailsPreviewContent(previewDetails(isSent = false, isConfirmed = false)) +} + +@Preview(name = "Pending Sent", showBackground = true) +@Composable +private fun TransactionDetailsPendingSentPreview() { + TransactionDetailsPreviewContent(previewDetails(isSent = true, isConfirmed = false)) +} + +@Composable +private fun TransactionDetailsPreviewContent(details: TransactionDetails) { + TransactionDetailsScreen( + app = null, + manager = null, + details = details, + isPreviewMode = true, + ) +} + +private fun previewDetails(isSent: Boolean, isConfirmed: Boolean): TransactionDetails = + PreviewTransactionDetails( + isSentTx = isSent, + isConfirmedTx = isConfirmed, + formattedDate = if (isConfirmed) "Nov 25, 2025 at 3:45 PM" else "", + address = "bc1q 4kmx 9uqq 0flm f232 0ntz 3apc z4en v97t", + amountPrimary = if (isSent) "-25,555 sats" else "+25,555 sats", + amountFiat = if (isSent) "-$28.88" else "$28.88", + feePrimary = if (isSent) "98 sats" else null, + feeFiat = if (isSent) "$0.02" else null, + sentSansFeePrimary = if (isSent) "25,457 sats" else null, + sentSansFeeFiat = if (isSent) "$28.86" else null, + totalSpentPrimary = if (isSent) "25,653 sats" else null, + ) + +// Lightweight preview-safe TransactionDetails that avoids loading native libs. +private class PreviewTransactionDetails( + private val isSentTx: Boolean, + private val isConfirmedTx: Boolean, + private val formattedDate: String, + private val address: String, + private val amountPrimary: String, + private val amountFiat: String, + private val feePrimary: String?, + private val feeFiat: String?, + private val sentSansFeePrimary: String?, + private val sentSansFeeFiat: String?, + private val totalSpentPrimary: String? = null, + private val url: String = "https://mempool.space/tx/preview", + private val txIdValue: String = "preview-tx-id", +) : TransactionDetails(CoreNoHandle) { + override fun isSent(): Boolean = isSentTx + override fun isReceived(): Boolean = !isSentTx + override fun isConfirmed(): Boolean = isConfirmedTx + override fun confirmationDateTime(): String? = formattedDate + override fun transactionUrl(): String = url + override fun txId(): TxId = PreviewTxId(txIdValue) + override fun amountFmt(unit: BitcoinUnit): String = amountPrimary + override suspend fun amountFiatFmt(): String = amountFiat + override fun feeFmt(unit: BitcoinUnit): String? = feePrimary + override suspend fun feeFiatFmt(): String = feeFiat ?: "" + override fun sentSansFeeFmt(unit: BitcoinUnit): String? = sentSansFeePrimary + override suspend fun sentSansFeeFiatFmt(): String = sentSansFeeFiat ?: "" + override fun amount(): Amount = PreviewAmount() + override fun addressSpacedOut(): String = address + override fun address(): Address = PreviewAddress(address.replace(" ", "")) + override fun blockNumberFmt(): String? = "123456" + override fun blockNumber(): UInt? = 123456u + override fun transactionLabel(): String? = null +} + +private class PreviewAmount : Amount(NoHandle) + +private class PreviewAddress(private val value: String) : Address(NoHandle) { + override fun string(): String = value +} + +private class PreviewTxId(private val value: String = "preview") : TxId(NoHandle) { + override fun toString(): String = value +} + /** * transaction details screen - now using manager-based pattern * ported from iOS TransactionDetailsView.swift @@ -111,12 +216,15 @@ private const val CONFIRMATIONS_THRESHOLD = 3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen( - app: AppManager, - manager: WalletManager, + app: AppManager?, + manager: WalletManager?, details: TransactionDetails, + isPreviewMode: Boolean = false, ) { val context = LocalContext.current - val metadata = manager.walletMetadata + val inspectionMode = LocalInspectionMode.current + val isPreview = isPreviewMode || inspectionMode || app == null || manager == null + val metadata = manager?.walletMetadata // state for confirmation polling var numberOfConfirmations by remember { mutableStateOf(null) } @@ -125,8 +233,8 @@ fun TransactionDetailsScreen( var sentSansFeeFiatFmt by remember { mutableStateOf("---") } var totalSpentFiatFmt by remember { mutableStateOf("---") } - // get current color scheme - val isDark = androidx.compose.foundation.isSystemInDarkTheme() + // get current color scheme (respects in-app theme toggle) + val isDark = !MaterialTheme.colorScheme.isLight // load fiat amounts LaunchedEffect(transactionDetails) { @@ -151,7 +259,9 @@ fun TransactionDetailsScreen( } // poll for confirmations if not fully confirmed - LaunchedEffect(transactionDetails.txId()) { + LaunchedEffect(transactionDetails, isPreview) { + if (isPreview || manager == null) return@LaunchedEffect + if (!transactionDetails.isConfirmed()) { delay(INITIAL_DELAY_MS) } @@ -210,27 +320,43 @@ fun TransactionDetailsScreen( val isConfirmed = transactionDetails.isConfirmed() // header icon presenter for dynamic colors - val presenter = remember { HeaderIconPresenter() } + val presenter = remember(isPreview) { if (isPreview) null else 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() + val circleColor = presenter?.backgroundColor(txState, direction, colorScheme, confirmationCount)?.toColor() + ?: if (isSent) Color(0xFF0F0F12) else CoveColor.TransactionReceived + val iconColor = presenter?.iconColor(txState, direction, colorScheme, confirmationCount)?.toColor() ?: Color.White - // get ring colors from presenter - apply opacity by multiplying with existing alpha - // iOS applies .opacity() which multiplies, so we do the same here + // get ring colors; fall back to static values in preview (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 { it.copy(alpha = it.alpha * ringOpacities[0]) }, - presenter.ringColor(txState, colorScheme, direction, confirmationCount, 2L).toColor() - .let { it.copy(alpha = it.alpha * ringOpacities[1]) }, - presenter.ringColor(txState, colorScheme, direction, confirmationCount, 3L).toColor() - .let { it.copy(alpha = it.alpha * ringOpacities[2]) }, - ) + val ringColors: List = + presenter?.let { + listOf( + it.ringColor(txState, colorScheme, direction, confirmationCount, 1L).toColor() + .let { color -> color.copy(alpha = color.alpha * ringOpacities[0]) }, + it.ringColor(txState, colorScheme, direction, confirmationCount, 2L).toColor() + .let { color -> color.copy(alpha = color.alpha * ringOpacities[1]) }, + it.ringColor(txState, colorScheme, direction, confirmationCount, 3L).toColor() + .let { color -> color.copy(alpha = color.alpha * ringOpacities[2]) }, + ) + } + ?: if (isDark) { + listOf( + Color.White.copy(alpha = 0.88f), + Color.White.copy(alpha = 0.66f), + Color.White.copy(alpha = 0.33f), + ) + } else { + listOf( + Color.Black.copy(alpha = 0.44f), + Color.Black.copy(alpha = 0.24f), + Color.Black.copy(alpha = 0.10f), + ) + } val headerTitle = stringResource( @@ -242,7 +368,12 @@ fun TransactionDetailsScreen( }, ) - val actionLabelRes = if (isSent) R.string.label_transaction_sent else R.string.label_transaction_received + 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 @@ -260,7 +391,12 @@ fun TransactionDetailsScreen( } // format amounts - val txAmountPrimary = manager.rust.displayAmount(amount = transactionDetails.amount()) + val txAmountPrimary = + if (manager != null) { + manager.rust.displayAmount(amount = transactionDetails.amount()) + } else { + transactionDetails.amountFmt(metadata?.selectedUnit ?: BitcoinUnit.SAT) + } val txAmountSecondary by androidx.compose.runtime.produceState(initialValue = "---") { value = try { @@ -286,7 +422,7 @@ fun TransactionDetailsScreen( ), title = { Text("") }, navigationIcon = { - IconButton(onClick = { app.popRoute() }) { + IconButton(onClick = { app?.popRoute() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, @@ -297,6 +433,12 @@ 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) + Box( modifier = Modifier @@ -311,8 +453,8 @@ fun TransactionDetailsScreen( Modifier .fillMaxHeight() .align(Alignment.TopCenter) - .offset(y = (-60).dp) - .alpha(if (isDark) 1.0f else 0.40f), + .offset(y = parallaxOffset) + .alpha(fadeAlpha), ) Column( @@ -320,13 +462,16 @@ fun TransactionDetailsScreen( Modifier .fillMaxSize() .padding(horizontal = 20.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(16.dp)) + val configuration = LocalConfiguration.current + val headerSize = (configuration.screenWidthDp * 0.33f).dp + CheckWithRingsWidget( - diameter = 180.dp, + diameter = headerSize, circleColor = circleColor, ringColors = ringColors, iconColor = iconColor, @@ -345,12 +490,14 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(4.dp)) - TransactionLabelView( - transactionDetails = transactionDetails, - manager = manager, - secondaryColor = sub, - snackbarHostState = snackbarHostState, - ) + if (!isPreview && manager != null) { + TransactionLabelView( + transactionDetails = transactionDetails, + manager = manager, + secondaryColor = sub, + snackbarHostState = snackbarHostState, + ) + } Spacer(Modifier.height(24.dp)) @@ -376,7 +523,7 @@ fun TransactionDetailsScreen( text = formattedDate, color = sub, fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = if (isReceived) FontWeight.SemiBold else FontWeight.Normal, textAlign = TextAlign.Center, lineHeight = 22.sp, ) @@ -392,6 +539,7 @@ fun TransactionDetailsScreen( text = "Please check back soon for an update.", color = sub, fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, lineHeight = 22.sp, ) @@ -404,7 +552,7 @@ fun TransactionDetailsScreen( txAmountPrimary, color = fg, baseFontSize = 36.sp, - minimumScaleFactor = 0.67f, + minimumScaleFactor = 0.01f, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), @@ -414,7 +562,7 @@ fun TransactionDetailsScreen( AutoSizeText( txAmountSecondary, - color = fg, + color = fg.copy(alpha = 0.8f), maxFontSize = 18.sp, minimumScaleFactor = 0.90f, ) @@ -422,13 +570,13 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(32.dp)) // transaction status capsule matching iOS styling - val coolGray = Color(0xFF787880) + val systemGreen = if (isDark) CoveColor.SystemGreenDark else CoveColor.SystemGreenLight val capsuleConfig = when { isReceived && isConfirmed -> { Triple( - Color(0xFF34C759).copy(alpha = 0.2f), // green with 20% opacity - Color(0xFF34C759), // green text + systemGreen.copy(alpha = 0.2f), // green with 20% opacity + systemGreen, // green text false, ) } @@ -441,7 +589,7 @@ fun TransactionDetailsScreen( } else -> { Triple( - coolGray, // coolGray background for pending + CoveColor.coolGray, // coolGray background for pending (iOS parity) Color.Black.copy(alpha = 0.8f), // black text at 80% opacity false, ) @@ -456,21 +604,25 @@ fun TransactionDetailsScreen( showStroke = capsuleConfig.third, ) + Spacer(Modifier.height(32.dp)) + // show confirmation indicator if < 3 confirmations if (numberOfConfirmations != null && numberOfConfirmations!! < 3) { - 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(), - ) + Column(modifier = Modifier.padding(horizontal = 28.dp)) { + 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(), + ) + } } AnimatedVisibility( @@ -479,7 +631,6 @@ fun TransactionDetailsScreen( exit = shrinkVertically() + fadeOut(), ) { TransactionDetailsWidget( - manager = manager, transactionDetails = transactionDetails, numberOfConfirmations = numberOfConfirmations, feeFiatFmt = feeFiatFmt, @@ -517,9 +668,11 @@ fun TransactionDetailsScreen( TextButton( onClick = { - manager.dispatch(WalletManagerAction.ToggleDetailsExpanded) + manager?.dispatch(WalletManagerAction.ToggleDetailsExpanded) }, - modifier = Modifier.align(Alignment.CenterHorizontally), + 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), @@ -537,7 +690,6 @@ fun TransactionDetailsScreen( @Composable private fun TransactionDetailsWidget( - manager: WalletManager, transactionDetails: TransactionDetails, numberOfConfirmations: Int?, feeFiatFmt: String, @@ -551,7 +703,8 @@ private fun TransactionDetailsWidget( val isSent = transactionDetails.isSent() val isConfirmed = transactionDetails.isConfirmed() - Spacer(Modifier.height(48.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(48.dp)) Box( modifier = Modifier @@ -567,14 +720,14 @@ private fun TransactionDetailsWidget( Text( stringResource(R.string.label_confirmations), color = sub, - fontSize = 14.sp, + fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) if (numberOfConfirmations != null) { Text( numberOfConfirmations.toString(), color = fg, - fontSize = 16.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, ) } @@ -583,13 +736,13 @@ private fun TransactionDetailsWidget( Text( stringResource(R.string.label_block_number), color = sub, - fontSize = 14.sp, + fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) Text( transactionDetails.blockNumberFmt() ?: "", color = fg, - fontSize = 16.sp, + fontSize = 14.sp, // iOS footnote parity fontWeight = FontWeight.SemiBold, ) } @@ -613,15 +766,15 @@ private fun TransactionDetailsWidget( Text( addressLabel, color = sub, - fontSize = 16.sp, + fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) Text( transactionDetails.addressSpacedOut(), color = fg, - fontSize = 20.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp, + lineHeight = 18.sp, ) // show block number and confirmations for confirmed sent transactions @@ -711,7 +864,8 @@ private fun TransactionDetailsWidget( ) } - Spacer(Modifier.height(72.dp)) + Spacer(Modifier.height(72.dp)) + } } @Composable @@ -738,7 +892,7 @@ private fun DetailsWidget( Text( label, color = labelColor, - fontSize = 18.sp, + fontSize = 12.sp, ) if (showInfoIcon) { Spacer(Modifier.width(8.dp)) @@ -757,10 +911,10 @@ private fun DetailsWidget( } } Column(horizontalAlignment = Alignment.End) { - AutoSizeText(primary, color = primaryColor, maxFontSize = 18.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.SemiBold) + AutoSizeText(primary, color = primaryColor, maxFontSize = 14.sp, minimumScaleFactor = 0.90f, fontWeight = FontWeight.SemiBold) if (!secondary.isNullOrEmpty()) { Spacer(Modifier.height(6.dp)) - Text(secondary, color = sub, fontSize = 14.sp) + Text(secondary, color = sub, fontSize = 12.sp) } } } @@ -786,15 +940,15 @@ private fun ReceivedTransactionDetails( Text( stringResource(R.string.label_received_at), color = sub, - fontSize = 16.sp, + fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) Text( transactionDetails.addressSpacedOut(), color = fg, - fontSize = 20.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp, + lineHeight = 18.sp, ) } @@ -923,25 +1077,26 @@ private fun CheckWithRingsWidget( iconColor: Color, isConfirmed: Boolean, ) { + val ringOffset = 10.dp + val totalSize = diameter + (ringOffset * ringColors.size * 2) + Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(diameter), + modifier = Modifier.size(totalSize), ) { Canvas(modifier = Modifier.matchParentSize()) { - val canvasMin = min(size.width, size.height) + val centerX = size.width / 2f + val centerY = size.height / 2f + val circleRadius = diameter.toPx() / 2f val stroke = 1.dp.toPx() - - val centerRadius = canvasMin * 0.35f - val maxRadius = (canvasMin / 2f) - (stroke / 2f) - val ringCount = ringColors.size - val totalExtra = (maxRadius - centerRadius).coerceAtLeast(0f) - val spacing = if (ringCount > 0) totalExtra / ringCount else 0f + val ringOffsetPx = ringOffset.toPx() ringColors.forEachIndexed { index, color -> - val r = centerRadius + ((index + 1) * spacing) + val r = circleRadius + ((index + 1) * ringOffsetPx) drawCircle( color = color, radius = r, + center = Offset(centerX, centerY), style = Stroke( width = stroke, @@ -953,14 +1108,14 @@ private fun CheckWithRingsWidget( Box( modifier = Modifier - .size(diameter * 0.7f) + .size(diameter) .clip(CircleShape) .background(circleColor), contentAlignment = Alignment.Center, ) { if (isConfirmed) { // draw checkmark with canvas for confirmed transactions - Canvas(modifier = Modifier.size(diameter * 0.36f)) { + Canvas(modifier = Modifier.size(diameter * 0.5f)) { val stroke = 3.dp.toPx() val w = size.width val h = size.height @@ -985,7 +1140,7 @@ private fun CheckWithRingsWidget( imageVector = Icons.Default.AccessTime, contentDescription = null, tint = iconColor, - modifier = Modifier.size(diameter * 0.36f), + modifier = Modifier.size(diameter * 0.5f), ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt index 182a406c8..421fba042 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt @@ -181,7 +181,7 @@ fun TransactionLabelView( textStyle = LocalTextStyle.current.copy( color = secondaryColor, - fontSize = 14.sp, + fontSize = 13.sp, ), cursorBrush = SolidColor(secondaryColor), modifier = @@ -225,7 +225,7 @@ fun TransactionLabelView( Text( text = currentLabel!!, color = secondaryColor, - fontSize = 14.sp, + fontSize = 13.sp, // iOS footnote parity ) if (isOperationInProgress) { @@ -295,7 +295,7 @@ fun TransactionLabelView( Text( text = stringResource(R.string.btn_add_label), color = secondaryColor, - fontSize = 14.sp, + fontSize = 13.sp, // iOS footnote parity ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt index 8f6f860d2..4e55df8b5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt @@ -15,7 +15,12 @@ object CoveColor { val coveLightGray = ComposeColor(0xFFE5EAEF) val duskBlue = ComposeColor(0xFF3A4254) - // Neutral colors (iOS parity when needed) + // system green colors (theme-aware) + val SystemGreenLight = ComposeColor(0xFF34C759) + val SystemGreenDark = ComposeColor(0xFF30D158) + + // Neutral colors + val coolGray = ComposeColor(0xFFD4D8D4) val almostGray = ComposeColor(0xFF787880) val almostWhite = ComposeColor(0xFFEBEDF0) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt index 665c974ae..d28fff5b2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt @@ -3,6 +3,7 @@ package org.bitcoinppl.cove.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -10,10 +11,18 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +/** + * Extension to check if the current ColorScheme is light mode. + * Uses surface luminance to reliably detect theme (works with dynamic colors). + */ +val ColorScheme.isLight: Boolean + get() = this.surface.luminance() > 0.5f + private val DarkColorScheme = darkColorScheme( // Brand colors (Cove identity) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt index 722524bfb..ab40a786e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt @@ -1,19 +1,32 @@ package org.bitcoinppl.cove.utils +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove_core.types.FfiColor import org.bitcoinppl.cove_core.types.FfiOpacity /** - * Convert FfiColor from Rust to Android Compose Color + * Convert FfiColor from Rust to Android Compose Color (theme-aware) * Matches iOS implementation in WalletColor+Ext.swift */ -fun FfiColor.toColor(): Color { +@Composable +fun FfiColor.toColor(): Color = toColor(isDark = !MaterialTheme.colorScheme.isLight) + +/** + * Convert FfiColor from Rust to Android Compose Color with explicit dark mode flag. + * Use this overload when not in a Composable context. + */ +fun FfiColor.toColor(isDark: Boolean): Color { return when (this) { is FfiColor.Red -> Color.Red.withOpacity(v1) is FfiColor.Blue -> Color.Blue.withOpacity(v1) - is FfiColor.Green -> Color.Green.withOpacity(v1) + is FfiColor.Green -> { + val green = if (isDark) CoveColor.SystemGreenDark else CoveColor.SystemGreenLight + green.withOpacity(v1) + } is FfiColor.Yellow -> Color.Yellow.withOpacity(v1) is FfiColor.Orange -> Color(0xFFFF9500).withOpacity(v1) is FfiColor.Purple -> Color(0xFFAF52DE).withOpacity(v1) @@ -21,7 +34,7 @@ fun FfiColor.toColor(): Color { is FfiColor.White -> Color.White.withOpacity(v1) is FfiColor.Black -> Color.Black.withOpacity(v1) is FfiColor.Gray -> Color.Gray.withOpacity(v1) - is FfiColor.CoolGray -> CoveColor.almostGray.withOpacity(v1) + is FfiColor.CoolGray -> CoveColor.coolGray.withOpacity(v1) is FfiColor.Custom -> { Color( red = v1.r.toFloat() / 255f, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 13b0b6d49..9e72469ca 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -1,6 +1,7 @@ package org.bitcoinppl.cove.views import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -146,24 +147,23 @@ fun BalanceAutoSizeText( BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } - // ensure the calculated size still fits, otherwise shrink further - val finalFontSize = remember(text, maxWidthPx, digitBasedFontSize, minFontSize) { - calculateOptimalFontSize( - text = text, - maxFontSize = digitBasedFontSize, - minFontSize = minFontSize, - maxWidthPx = maxWidthPx, - style = style, - fontWeight = fontWeight, - fontStyle = null, - fontFamily = null, - density = density, - fontFamilyResolver = fontFamilyResolver, - ) - } + // calculate optimal font size (no remember - needs fresh calculation based on actual width) + val finalFontSize = calculateOptimalFontSize( + text = text, + maxFontSize = digitBasedFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = null, + fontFamily = null, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) Text( text = text, + modifier = Modifier.fillMaxWidth(), color = color, fontSize = finalFontSize, fontWeight = fontWeight, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt index a676419e5..a2273a37b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt @@ -4,7 +4,6 @@ import android.content.ClipData import android.content.ClipboardManager import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -48,6 +47,7 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.QrCodeGenerator import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove_core.types.AddressInfoWithDerivation @OptIn(ExperimentalMaterial3Api::class) @@ -60,7 +60,6 @@ fun ReceiveAddressSheet( val context = LocalContext.current val scope = rememberCoroutineScope() val tag = "ReceiveAddressSheet" - val isDarkTheme = isSystemInDarkTheme() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var addressInfo by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } @@ -147,7 +146,7 @@ private fun ReceiveAddressSheetContent( onCopyAddress: () -> Unit, onCreateNewAddress: () -> Unit, ) { - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = !MaterialTheme.colorScheme.isLight Column( modifier = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 1e9f2635a..53712bd37 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -3,7 +3,6 @@ package org.bitcoinppl.cove.wallet_transactions import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -62,6 +61,7 @@ import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletLoadState import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton @@ -345,6 +345,7 @@ fun WalletTransactionsScreen( manager = manager, ) } + is Transaction.Unconfirmed -> { val direction = txn.v1.sentAndReceived().direction() val txType = @@ -416,20 +417,22 @@ private fun TransactionWidget( ) val scope = rememberCoroutineScope() - val isDark = isSystemInDarkTheme() + val isDark = !MaterialTheme.colorScheme.isLight // get transaction id for navigation - val txId = when (transaction) { - is Transaction.Confirmed -> transaction.v1.id() - is Transaction.Unconfirmed -> transaction.v1.id() - } + val txId = + when (transaction) { + is Transaction.Confirmed -> transaction.v1.id() + is Transaction.Unconfirmed -> transaction.v1.id() + } // icon background color based on dark mode - val iconBackground = if (isDark) { - Color.Gray.copy(alpha = 0.35f) - } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.75f) - } + val iconBackground = + if (isDark) { + Color.Gray.copy(alpha = 0.35f) + } else { + Color.Black.copy(alpha = 0.75f) + } Row( modifier = @@ -450,8 +453,7 @@ private fun TransactionWidget( } } } - } - .padding(vertical = 6.dp), + }.padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -470,15 +472,15 @@ private fun TransactionWidget( ) } - Spacer(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(12.dp)) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( text = title, - color = primaryText, + color = primaryText.copy(alpha = 0.65f), fontSize = 15.sp, fontWeight = FontWeight.Medium, ) @@ -494,14 +496,14 @@ private fun TransactionWidget( Column(horizontalAlignment = Alignment.End) { Text( text = amount, - color = if (type == TransactionType.RECEIVED) CoveColor.TransactionReceived else primaryText, + color = if (type == TransactionType.RECEIVED) CoveColor.TransactionReceived else primaryText.copy(alpha = 0.8f), fontSize = 17.sp, fontWeight = FontWeight.Normal, ) Text( text = balanceAfter, color = secondaryText, - fontSize = 14.sp, + fontSize = 12.sp, fontWeight = FontWeight.Normal, ) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6b3ac913e..854669d9f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -41,6 +41,8 @@ Receive Sent Received + Sending + Receiving Transactions No transactions yet Pending From 92ebafa1f5a83207438ff2ae3b5247ecd832edc7 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 08:49:33 -0600 Subject: [PATCH 10/97] Update CoveNavDisplay.kt --- .../cove/navigation/CoveNavDisplay.kt | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index 41ac8017f..e78732362 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.navigation -import androidx.activity.compose.BackHandler import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -39,11 +38,9 @@ fun CoveNavDisplay( app: AppManager, modifier: Modifier = Modifier, ) { - // initialize backStack synchronously with current routes (NOT empty!) - // NavDisplay requires at least one entry or it crashes + // include default route at bottom so NavDisplay knows back is possible val initialRoutes = remember { - val routes = app.router.routes - if (routes.isEmpty()) listOf(app.router.default) else routes + listOf(app.router.default) + app.router.routes } val backStack = remember { mutableStateListOf().apply { addAll(initialRoutes) } @@ -51,31 +48,23 @@ fun CoveNavDisplay( // sync back stack when FFI routes change LaunchedEffect(app.router.routes, app.router.default) { - val ffiRoutes = app.router.routes - val currentBackStack = backStack.toList() - - // only update if different to avoid unnecessary recompositions - if (ffiRoutes != currentBackStack) { + val newBackStack = listOf(app.router.default) + app.router.routes + if (newBackStack != backStack.toList()) { backStack.clear() - if (ffiRoutes.isEmpty()) { - // if no routes, use default as the single route - backStack.add(app.router.default) - } else { - backStack.addAll(ffiRoutes) - } + backStack.addAll(newBackStack) } } - // handle hardware back button (only when navigation is possible) - BackHandler(enabled = app.rust.canGoBack()) { - app.popRoute() - } + // no BackHandler - let NavDisplay handle predictive back natively NavDisplay( backStack = backStack, onBack = { - // sync back to FFI when user navigates back - app.popRoute() + // directly modify backStack for predictive back support + if (backStack.size > 1) { + backStack.removeLast() + app.popRoute() + } }, modifier = modifier, // Material Design Shared Axis X transitions (horizontal slide + fade) From 768c41a533ce9eb80a4ffb0ef00d292da3bc77a1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 09:17:41 -0600 Subject: [PATCH 11/97] Make sidebar work with back navigation --- .../java/org/bitcoinppl/cove/MainActivity.kt | 13 +- .../org/bitcoinppl/cove/QrCodeScanView.kt | 18 +- .../hot_wallet/HotWalletImportScreen.kt | 50 +-- .../cove/navigation/CoveNavDisplay.kt | 50 +-- .../cove/sheets/FeeRateSelectorSheet.kt | 1 - .../cove/sidebar/SidebarContainer.kt | 65 ++-- .../bitcoinppl/cove/sidebar/SidebarView.kt | 1 - .../TransactionDetailsScreen.kt | 318 ++++++++++-------- .../org/bitcoinppl/cove/utils/FfiColorExt.kt | 5 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 112 +++--- .../WalletTransactionsScreen.kt | 1 - 11 files changed, 348 insertions(+), 286 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 33529021a..0b50537a2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -27,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.isSystemInDarkTheme import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer @@ -49,11 +49,12 @@ class MainActivity : ComponentActivity() { // compute dark theme based on user preference val systemDarkTheme = isSystemInDarkTheme() - val darkTheme = when (app.colorSchemeSelection) { - ColorSchemeSelection.DARK -> true - ColorSchemeSelection.LIGHT -> false - ColorSchemeSelection.SYSTEM -> systemDarkTheme - } + val darkTheme = + when (app.colorSchemeSelection) { + ColorSchemeSelection.DARK -> true + ColorSchemeSelection.LIGHT -> false + ColorSchemeSelection.SYSTEM -> systemDarkTheme + } CoveTheme(darkTheme = darkTheme) { var initError by remember { mutableStateOf(null) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index 5f3115a1b..4f93f833b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -72,7 +72,7 @@ fun QrCodeScanView( val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) Box( - modifier = modifier.fillMaxSize().background(Color.Black) + modifier = modifier.fillMaxSize().background(Color.Black), ) { when { cameraPermissionState.status.isGranted -> { @@ -96,10 +96,11 @@ fun QrCodeScanView( if (showTopBar) { IconButton( onClick = onDismiss, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(16.dp) + modifier = + Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(16.dp), ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, @@ -287,9 +288,10 @@ private fun QrScannerContent( Icon( imageVector = Icons.Outlined.CropFree, contentDescription = "Scan area", - modifier = Modifier - .size(200.dp) - .align(Alignment.Center), + modifier = + Modifier + .size(200.dp) + .align(Alignment.Center), tint = Color.White.copy(alpha = 0.7f), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index d91bae22b..c034896ad 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed @@ -558,11 +557,12 @@ private fun WordInputField( // update suggestions when word or focus changes LaunchedEffect(word, isFocused) { - suggestions = if (isFocused && word.isNotEmpty()) { - autocomplete.autocomplete(word, allEnteredWords) - } else { - emptyList() - } + suggestions = + if (isFocused && word.isNotEmpty()) { + autocomplete.autocomplete(word, allEnteredWords) + } else { + emptyList() + } } Column { @@ -638,17 +638,17 @@ private fun WordInputField( // suggestion dropdown if (suggestions.isNotEmpty()) { Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = CoveColor.midnightBlue.copy(alpha = 0.95f), - shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), - ) - .border( - width = 1.dp, - color = CoveColor.coveLightGray.copy(alpha = 0.3f), - shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), - ), + modifier = + Modifier + .fillMaxWidth() + .background( + color = CoveColor.midnightBlue.copy(alpha = 0.95f), + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + ).border( + width = 1.dp, + color = CoveColor.coveLightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + ), ) { suggestions.forEach { suggestion -> Text( @@ -656,14 +656,14 @@ private fun WordInputField( color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, - modifier = Modifier - .fillMaxWidth() - .clickable { - onWordChanged(suggestion) - suggestions = emptyList() - onNext() - } - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + onWordChanged(suggestion) + suggestions = emptyList() + onNext() + }.padding(horizontal = 12.dp, vertical = 10.dp), ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index e78732362..b678bb6ba 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -39,12 +39,14 @@ fun CoveNavDisplay( modifier: Modifier = Modifier, ) { // include default route at bottom so NavDisplay knows back is possible - val initialRoutes = remember { - listOf(app.router.default) + app.router.routes - } - val backStack = remember { - mutableStateListOf().apply { addAll(initialRoutes) } - } + val initialRoutes = + remember { + listOf(app.router.default) + app.router.routes + } + val backStack = + remember { + mutableStateListOf().apply { addAll(initialRoutes) } + } // sync back stack when FFI routes change LaunchedEffect(app.router.routes, app.router.default) { @@ -87,14 +89,16 @@ private fun forwardTransition(): ContentTransform = slideInHorizontally( initialOffsetX = { it }, animationSpec = tween(300, easing = MaterialMotion.emphasizedDecelerate), - ) + fadeIn( - animationSpec = tween(200, delayMillis = 100), - ) togetherWith slideOutHorizontally( - targetOffsetX = { -it / 3 }, - animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), - ) + fadeOut( - animationSpec = tween(100), - ) + ) + + fadeIn( + animationSpec = tween(200, delayMillis = 100), + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it / 3 }, + animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), + ) + + fadeOut( + animationSpec = tween(100), + ) /** * Material SharedAxis X transition for backward navigation (pop) @@ -104,14 +108,16 @@ private fun backwardTransition(): ContentTransform = slideInHorizontally( initialOffsetX = { -it / 3 }, animationSpec = tween(300, easing = MaterialMotion.emphasizedDecelerate), - ) + fadeIn( - animationSpec = tween(200, delayMillis = 100), - ) togetherWith slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), - ) + fadeOut( - animationSpec = tween(100), - ) + ) + + fadeIn( + animationSpec = tween(200, delayMillis = 100), + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(300, easing = MaterialMotion.emphasizedAccelerate), + ) + + fadeOut( + animationSpec = tween(100), + ) /** * Maps FFI Route to screen content 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 14c183713..90dd3695d 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 @@ -20,7 +20,6 @@ import org.bitcoinppl.cove.SendFlowManager import org.bitcoinppl.cove.SendFlowPresenter import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor -import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove_core.types.FeeRateOptionWithTotalFee import org.bitcoinppl.cove_core.types.FeeRateOptionsWithTotalFee import org.bitcoinppl.cove_core.types.FeeSpeed diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index 4d0c4861c..a59a5c356 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -1,5 +1,7 @@ package org.bitcoinppl.cove.sidebar +import android.app.Activity +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.foundation.background @@ -16,15 +18,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt private const val SIDEBAR_WIDTH_DP = 280f @@ -35,6 +39,7 @@ fun SidebarContainer( app: AppManager, content: @Composable () -> Unit, ) { + val context = LocalContext.current val density = LocalDensity.current val sidebarWidthPx = with(density) { SIDEBAR_WIDTH_DP.dp.toPx() } @@ -60,10 +65,11 @@ fun SidebarContainer( if (!isDragging) { animatedOffset.animateTo( targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), + animationSpec = + spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), ) } } @@ -82,6 +88,21 @@ fun SidebarContainer( // only enable gestures when at root (no routes) - use reactive state val gesturesEnabled = app.router.routes.isEmpty() + // at root: back opens sidebar, or exits app if sidebar already open + PredictiveBackHandler(enabled = gesturesEnabled) { backEvents -> + try { + backEvents.collect { } + } catch (e: CancellationException) { + throw e + } + + if (app.isSidebarVisible) { + (context as? Activity)?.moveTaskToBack(true) + } else { + app.isSidebarVisible = true + } + } + Box( modifier = Modifier.fillMaxSize(), ) { @@ -99,8 +120,7 @@ fun SidebarContainer( app.isSidebarVisible = false } } - } - .pointerInput(Unit) { + }.pointerInput(Unit) { var totalDrag = 0f detectHorizontalDragGestures( onDragStart = { @@ -139,11 +159,12 @@ fun SidebarContainer( gestureOffset = 0f // determine if this is a valid drag that should affect the sidebar - isValidDrag = when { - app.isSidebarVisible -> true - offset.x < edgeThresholdPx -> true - else -> false - } + isValidDrag = + when { + app.isSidebarVisible -> true + offset.x < edgeThresholdPx -> true + else -> false + } }, onDragEnd = { if (isDragging && isValidDrag) { @@ -158,10 +179,11 @@ fun SidebarContainer( animatedOffset.snapTo(currentDragPosition) animatedOffset.animateTo( targetValue = if (shouldBeOpen) sidebarWidthPx else 0f, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), + animationSpec = + spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), ) } } @@ -177,10 +199,11 @@ fun SidebarContainer( animatedOffset.snapTo(currentDragPosition) animatedOffset.animateTo( targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 700f, - ), + animationSpec = + spring( + dampingRatio = 0.8f, + stiffness = 700f, + ), ) } } @@ -205,7 +228,7 @@ fun SidebarContainer( } } else { Modifier - } + }, ), ) { content() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index b9d512bb0..ae3fa5ea0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.AppManager diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 6ade0f292..5390ecfd1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -29,7 +29,6 @@ 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.ScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -40,7 +39,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.NorthEast import androidx.compose.material.icons.filled.SouthWest import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -68,7 +66,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -98,15 +95,14 @@ import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletMetadata -import org.bitcoinppl.cove_core.NoHandle as CoreNoHandle -import org.bitcoinppl.cove_core.types.NoHandle +import org.bitcoinppl.cove_core.types.Address +import org.bitcoinppl.cove_core.types.Amount import org.bitcoinppl.cove_core.types.BitcoinUnit import org.bitcoinppl.cove_core.types.FfiColorScheme +import org.bitcoinppl.cove_core.types.NoHandle import org.bitcoinppl.cove_core.types.TransactionDirection -import org.bitcoinppl.cove_core.types.Address -import org.bitcoinppl.cove_core.types.Amount import org.bitcoinppl.cove_core.types.TxId -import kotlin.math.min +import org.bitcoinppl.cove_core.NoHandle as CoreNoHandle private const val INITIAL_DELAY_MS = 2000L private const val FREQUENT_POLL_INTERVAL_MS = 30000L @@ -180,32 +176,53 @@ private class PreviewTransactionDetails( private val txIdValue: String = "preview-tx-id", ) : TransactionDetails(CoreNoHandle) { override fun isSent(): Boolean = isSentTx + override fun isReceived(): Boolean = !isSentTx + override fun isConfirmed(): Boolean = isConfirmedTx + override fun confirmationDateTime(): String? = formattedDate + override fun transactionUrl(): String = url + override fun txId(): TxId = PreviewTxId(txIdValue) + override fun amountFmt(unit: BitcoinUnit): String = amountPrimary + override suspend fun amountFiatFmt(): String = amountFiat + override fun feeFmt(unit: BitcoinUnit): String? = feePrimary + override suspend fun feeFiatFmt(): String = feeFiat ?: "" + override fun sentSansFeeFmt(unit: BitcoinUnit): String? = sentSansFeePrimary + override suspend fun sentSansFeeFiatFmt(): String = sentSansFeeFiat ?: "" + override fun amount(): Amount = PreviewAmount() + override fun addressSpacedOut(): String = address + override fun address(): Address = PreviewAddress(address.replace(" ", "")) + override fun blockNumberFmt(): String? = "123456" + override fun blockNumber(): UInt? = 123456u + override fun transactionLabel(): String? = null } private class PreviewAmount : Amount(NoHandle) -private class PreviewAddress(private val value: String) : Address(NoHandle) { +private class PreviewAddress( + private val value: String, +) : Address(NoHandle) { override fun string(): String = value } -private class PreviewTxId(private val value: String = "preview") : TxId(NoHandle) { +private class PreviewTxId( + private val value: String = "preview", +) : TxId(NoHandle) { override fun toString(): String = value } @@ -327,8 +344,9 @@ fun TransactionDetailsScreen( val confirmationCount = numberOfConfirmations?.toLong() ?: if (isConfirmed) 5L else 0L // get colors from presenter (matching iOS) - val circleColor = presenter?.backgroundColor(txState, direction, colorScheme, confirmationCount)?.toColor() - ?: if (isSent) Color(0xFF0F0F12) else CoveColor.TransactionReceived + val circleColor = + presenter?.backgroundColor(txState, direction, colorScheme, confirmationCount)?.toColor() + ?: if (isSent) Color(0xFF0F0F12) else CoveColor.TransactionReceived val iconColor = presenter?.iconColor(txState, direction, colorScheme, confirmationCount)?.toColor() ?: Color.White // get ring colors; fall back to static values in preview (matching iOS opacities) @@ -336,11 +354,17 @@ fun TransactionDetailsScreen( val ringColors: List = presenter?.let { listOf( - it.ringColor(txState, colorScheme, direction, confirmationCount, 1L).toColor() + it + .ringColor(txState, colorScheme, direction, confirmationCount, 1L) + .toColor() .let { color -> color.copy(alpha = color.alpha * ringOpacities[0]) }, - it.ringColor(txState, colorScheme, direction, confirmationCount, 2L).toColor() + it + .ringColor(txState, colorScheme, direction, confirmationCount, 2L) + .toColor() .let { color -> color.copy(alpha = color.alpha * ringOpacities[1]) }, - it.ringColor(txState, colorScheme, direction, confirmationCount, 3L).toColor() + it + .ringColor(txState, colorScheme, direction, confirmationCount, 3L) + .toColor() .let { color -> color.copy(alpha = color.alpha * ringOpacities[2]) }, ) } @@ -368,12 +392,13 @@ fun TransactionDetailsScreen( }, ) - 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 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 @@ -670,9 +695,10 @@ fun TransactionDetailsScreen( onClick = { manager?.dispatch(WalletManagerAction.ToggleDetailsExpanded) }, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .offset(y = (-20).dp), + 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), @@ -705,142 +731,115 @@ private fun TransactionDetailsWidget( Column(modifier = Modifier.fillMaxWidth()) { Spacer(Modifier.height(48.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(dividerColor), - ) - Spacer(Modifier.height(24.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(dividerColor), + ) + Spacer(Modifier.height(24.dp)) - // show confirmations if confirmed - if (isConfirmed) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.label_confirmations), - color = sub, - fontSize = 12.sp, - ) - Spacer(Modifier.height(8.dp)) - if (numberOfConfirmations != null) { + // show confirmations if confirmed + if (isConfirmed) { + Column(modifier = Modifier.fillMaxWidth()) { Text( - numberOfConfirmations.toString(), + stringResource(R.string.label_confirmations), + color = sub, + fontSize = 12.sp, + ) + Spacer(Modifier.height(8.dp)) + if (numberOfConfirmations != null) { + Text( + numberOfConfirmations.toString(), + color = fg, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(14.dp)) + + Text( + stringResource(R.string.label_block_number), + color = sub, + fontSize = 12.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + transactionDetails.blockNumberFmt() ?: "", color = fg, - fontSize = 14.sp, + fontSize = 14.sp, // iOS footnote parity fontWeight = FontWeight.SemiBold, ) } - Spacer(Modifier.height(14.dp)) + Spacer(Modifier.height(24.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(dividerColor), + ) + Spacer(Modifier.height(24.dp)) + } + // address (sent to / received from) + val addressLabel = + stringResource( + if (isSent) R.string.label_sent_to else R.string.label_received_from, + ) + Column(modifier = Modifier.fillMaxWidth()) { Text( - stringResource(R.string.label_block_number), + addressLabel, color = sub, fontSize = 12.sp, ) Spacer(Modifier.height(8.dp)) Text( - transactionDetails.blockNumberFmt() ?: "", + transactionDetails.addressSpacedOut(), color = fg, - fontSize = 14.sp, // iOS footnote parity + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp, ) - } - Spacer(Modifier.height(24.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(dividerColor), - ) - Spacer(Modifier.height(24.dp)) - } - // address (sent to / received from) - val addressLabel = - stringResource( - if (isSent) R.string.label_sent_to else R.string.label_received_from, - ) - Column(modifier = Modifier.fillMaxWidth()) { - Text( - addressLabel, - color = sub, - fontSize = 12.sp, - ) - Spacer(Modifier.height(8.dp)) - Text( - transactionDetails.addressSpacedOut(), - color = fg, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 18.sp, - ) - - // show block number and confirmations for confirmed sent transactions - if (isSent && isConfirmed) { - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - transactionDetails.blockNumberFmt() ?: "", - color = sub, - fontSize = 14.sp, - ) - Text(" | ", color = sub, fontSize = 14.sp) - if (numberOfConfirmations != null) { + // show block number and confirmations for confirmed sent transactions + if (isSent && isConfirmed) { + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { Text( - numberOfConfirmations.toString(), + transactionDetails.blockNumberFmt() ?: "", color = sub, fontSize = 14.sp, ) - Spacer(Modifier.size(4.dp)) - Box( - modifier = - Modifier - .size(14.dp) - .clip(CircleShape) - .background(CoveColor.SuccessGreen), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(10.dp), + Text(" | ", color = sub, fontSize = 14.sp) + if (numberOfConfirmations != null) { + Text( + numberOfConfirmations.toString(), + color = sub, + fontSize = 14.sp, ) + Spacer(Modifier.size(4.dp)) + Box( + modifier = + Modifier + .size(14.dp) + .clip(CircleShape) + .background(CoveColor.SuccessGreen), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(10.dp), + ) + } } } } } - } - Spacer(Modifier.height(24.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(dividerColor), - ) - Spacer(Modifier.height(24.dp)) - - // network fee (for sent transactions) - if (isSent) { - DetailsWidget( - label = stringResource(R.string.label_network_fee), - primary = transactionDetails.feeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $feeFiatFmt", - showInfoIcon = true, - onInfoClick = { /* TODO: show fee info */ }, - ) Spacer(Modifier.height(24.dp)) - - DetailsWidget( - label = stringResource(R.string.label_recipient_receives), - primary = transactionDetails.sentSansFeeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $sentSansFeeFiatFmt", - ) - Spacer(Modifier.height(24.dp)) - Box( modifier = Modifier @@ -850,19 +849,46 @@ private fun TransactionDetailsWidget( ) Spacer(Modifier.height(24.dp)) - DetailsWidget( - label = stringResource(R.string.label_total_spent), - primary = transactionDetails.amountFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $totalSpentFiatFmt", - isTotal = true, - ) - } else { - // received transaction details - ReceivedTransactionDetails( - transactionDetails = transactionDetails, - numberOfConfirmations = numberOfConfirmations, - ) - } + // network fee (for sent transactions) + if (isSent) { + DetailsWidget( + label = stringResource(R.string.label_network_fee), + primary = transactionDetails.feeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + secondary = "≈ $feeFiatFmt", + showInfoIcon = true, + onInfoClick = { /* TODO: show fee info */ }, + ) + Spacer(Modifier.height(24.dp)) + + DetailsWidget( + label = stringResource(R.string.label_recipient_receives), + primary = transactionDetails.sentSansFeeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + secondary = "≈ $sentSansFeeFiatFmt", + ) + Spacer(Modifier.height(24.dp)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(dividerColor), + ) + Spacer(Modifier.height(24.dp)) + + DetailsWidget( + label = stringResource(R.string.label_total_spent), + primary = transactionDetails.amountFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + secondary = "≈ $totalSpentFiatFmt", + isTotal = true, + ) + } else { + // received transaction details + ReceivedTransactionDetails( + transactionDetails = transactionDetails, + numberOfConfirmations = numberOfConfirmations, + ) + } Spacer(Modifier.height(72.dp)) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt index ab40a786e..6ac78eeb8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/utils/FfiColorExt.kt @@ -19,8 +19,8 @@ fun FfiColor.toColor(): Color = toColor(isDark = !MaterialTheme.colorScheme.isLi * Convert FfiColor from Rust to Android Compose Color with explicit dark mode flag. * Use this overload when not in a Composable context. */ -fun FfiColor.toColor(isDark: Boolean): Color { - return when (this) { +fun FfiColor.toColor(isDark: Boolean): Color = + when (this) { is FfiColor.Red -> Color.Red.withOpacity(v1) is FfiColor.Blue -> Color.Blue.withOpacity(v1) is FfiColor.Green -> { @@ -43,7 +43,6 @@ fun FfiColor.toColor(isDark: Boolean): Color { ).withOpacity(v2) } } -} /** * Apply FfiOpacity (0-100) to a Color diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 9e72469ca..a597ec870 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -64,20 +64,21 @@ fun AutoSizeText( val maxWidthPx = with(density) { maxWidth.toPx() } // calculate optimal font size - val fontSize = remember(text, maxWidthPx, maxFontSize, minFontSize) { - calculateOptimalFontSize( - text = text, - maxFontSize = maxFontSize, - minFontSize = minFontSize, - maxWidthPx = maxWidthPx, - style = style, - fontWeight = fontWeight, - fontStyle = fontStyle, - fontFamily = fontFamily, - density = density, - fontFamilyResolver = fontFamilyResolver, - ) - } + val fontSize = + remember(text, maxWidthPx, maxFontSize, minFontSize) { + calculateOptimalFontSize( + text = text, + maxFontSize = maxFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + } Text( text = text, @@ -127,39 +128,42 @@ fun BalanceAutoSizeText( val minFontSize = baseFontSize * minimumScaleFactor // extract numeric value to count digits (matches iOS algorithm) - val digits = remember(text) { - val numericText = text.replace(Regex("[^0-9.]"), "") - val number = numericText.toDoubleOrNull() ?: 0.0 - if (number > 0) { - floor(log10(number)).toInt() + 1 - } else { - 1 + val digits = + remember(text) { + val numericText = text.replace(Regex("[^0-9.]"), "") + val number = numericText.toDoubleOrNull() ?: 0.0 + if (number > 0) { + floor(log10(number)).toInt() + 1 + } else { + 1 + } } - } // calculate font size based on digits: max(baseFontSize - (digits - 1) * 2, minFontSize) - val digitBasedFontSize = remember(digits, baseFontSize, minFontSize) { - val reduction = (digits - 1) * 2 - val calculated = baseFontSize.value - reduction - maxOf(calculated, minFontSize.value).sp - } + val digitBasedFontSize = + remember(digits, baseFontSize, minFontSize) { + val reduction = (digits - 1) * 2 + val calculated = baseFontSize.value - reduction + maxOf(calculated, minFontSize.value).sp + } BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } // calculate optimal font size (no remember - needs fresh calculation based on actual width) - val finalFontSize = calculateOptimalFontSize( - text = text, - maxFontSize = digitBasedFontSize, - minFontSize = minFontSize, - maxWidthPx = maxWidthPx, - style = style, - fontWeight = fontWeight, - fontStyle = null, - fontFamily = null, - density = density, - fontFamilyResolver = fontFamilyResolver, - ) + val finalFontSize = + calculateOptimalFontSize( + text = text, + maxFontSize = digitBasedFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = null, + fontFamily = null, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) Text( text = text, @@ -199,21 +203,25 @@ private fun calculateOptimalFontSize( // binary search for optimal font size while (low <= high) { val mid = (low + high) / 2f - val testStyle = style.copy( - fontSize = mid.sp, - fontWeight = fontWeight, - fontStyle = fontStyle, - fontFamily = fontFamily, - ) + val testStyle = + style.copy( + fontSize = mid.sp, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + ) // measure text width at this font size - val paragraph = androidx.compose.ui.text.Paragraph( - text = text, - style = testStyle, - constraints = androidx.compose.ui.unit.Constraints(maxWidth = Int.MAX_VALUE), - density = density, - fontFamilyResolver = fontFamilyResolver, - ) + val paragraph = + androidx.compose.ui.text.Paragraph( + text = text, + style = testStyle, + constraints = + androidx.compose.ui.unit + .Constraints(maxWidth = Int.MAX_VALUE), + density = density, + fontFamilyResolver = fontFamilyResolver, + ) if (paragraph.minIntrinsicWidth <= maxWidthPx) { // text fits, try larger size diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 53712bd37..8060c3449 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.layout.ContentScale 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.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp From 1e8dec9de237e433ab32afefcad54206b94c5351 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 11:22:18 -0600 Subject: [PATCH 12/97] Fixes --- android/app/src/main/AndroidManifest.xml | 1 + .../java/org/bitcoinppl/cove/AppManager.kt | 4 + .../java/org/bitcoinppl/cove/AppSheetState.kt | 2 + .../java/org/bitcoinppl/cove/MainActivity.kt | 7 + .../org/bitcoinppl/cove/QrCodeScanView.kt | 121 +++++++- .../flow/new_wallet/NewWalletSelectScreen.kt | 45 ++- .../cove/flow/new_wallet/NfcHelpSheet.kt | 95 +++++++ .../hot_wallet/HotWalletVerifyScreen.kt | 154 +++++++--- .../cove/import_wallet/ImportWalletScreen.kt | 16 +- .../org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 164 +++++++++++ .../SendConfirmationScreen.kt | 6 +- .../cove/sidebar/SidebarContainer.kt | 78 ++--- .../bitcoinppl/cove/sidebar/SidebarView.kt | 6 +- .../TransactionDetailsScreen.kt | 2 +- .../bitcoinppl/cove/utils/WalletColorExt.kt | 7 + .../bitcoinppl/cove/views/CustomBaseViews.kt | 34 +++ .../bitcoinppl/cove/views/RecoveryWords.kt | 8 +- .../org/bitcoinppl/cove/views/WalletIcon.kt | 7 +- .../WalletTransactionsScreen.kt | 268 +++++++++++------- .../res/drawable/ic_launcher_background.xml | 162 +---------- .../res/drawable/ic_launcher_foreground.xml | 138 +++++++-- 21 files changed, 935 insertions(+), 390 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NfcHelpSheet.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d209210d8..4e27e03e4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + { + NfcScanSheet( + app = app, + onDismiss = onDismiss, + ) + } is AppSheetState.TapSigner -> { // TapSigner sheets would go here if needed // Currently handled elsewhere in the app diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index 4f93f833b..3a1ffdf79 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -1,17 +1,27 @@ package org.bitcoinppl.cove import android.Manifest +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import android.util.Base64 import android.util.Log +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.outlined.CropFree import androidx.compose.material3.* import androidx.compose.runtime.* @@ -35,6 +45,31 @@ import org.bitcoinppl.cove_core.MultiQr import org.bitcoinppl.cove_core.StringOrData import java.util.concurrent.Executors +// haptic feedback helper +private fun triggerHapticFeedback(context: Context) { + try { + val vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + vibrator?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + it.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + it.vibrate(50) + } + } + } catch (e: Exception) { + Log.w("QrCodeScanView", "Failed to trigger haptic feedback", e) + } +} + private sealed class QrCodeScannerState { object Idle : QrCodeScannerState() @@ -188,6 +223,27 @@ private fun QrScannerContent( val cameraProviderRef = remember { mutableStateOf(null) } val previewRef = remember { mutableStateOf(null) } val analysisRef = remember { mutableStateOf(null) } + val cameraRef = remember { mutableStateOf(null) } + + // flashlight and zoom state + var isFlashOn by remember { mutableStateOf(false) } + var zoomLevel by remember { mutableStateOf(1.0f) } // 1.0 = 1x, 2.0 = 2x + + // toggle flashlight + fun toggleFlash() { + cameraRef.value?.let { camera -> + isFlashOn = !isFlashOn + camera.cameraControl.enableTorch(isFlashOn) + } + } + + // toggle zoom between 1x and 2x + fun toggleZoom() { + cameraRef.value?.let { camera -> + zoomLevel = if (zoomLevel == 1.0f) 2.0f else 1.0f + camera.cameraControl.setZoomRatio(zoomLevel) + } + } Box(modifier = modifier) { when (val state = scannerState) { @@ -234,6 +290,7 @@ private fun QrScannerContent( for (barcode in barcodes) { if (barcode.format == Barcode.FORMAT_QR_CODE) { handleQrCode( + context = ctx, currentState = scannerState, barcode = barcode, scannedCodes = scannedCodes, @@ -260,12 +317,14 @@ private fun QrScannerContent( try { cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageAnalysis, - ) + val camera = + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis, + ) + cameraRef.value = camera } catch (e: Exception) { Log.e("QrCodeScanView", "Camera binding failed", e) onDismiss() @@ -284,6 +343,50 @@ private fun QrScannerContent( modifier = Modifier.fillMaxSize(), ) + // flashlight and zoom controls - top of screen + Row( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 60.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // flashlight toggle - top left + Box( + modifier = + Modifier + .size(44.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + .clickable { toggleFlash() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isFlashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, + contentDescription = if (isFlashOn) "Turn off flashlight" else "Turn on flashlight", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + // zoom toggle - top right + Box( + modifier = + Modifier + .size(44.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + .clickable { toggleZoom() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = if (zoomLevel == 1.0f) "1x" else "2x", + color = Color.White, + fontWeight = FontWeight.Medium, + ) + } + } + // viewfinder overlay - centered Icon( imageVector = Icons.Outlined.CropFree, @@ -367,6 +470,7 @@ private fun QrScannerContent( } private fun handleQrCode( + context: Context, currentState: QrCodeScannerState, barcode: Barcode, scannedCodes: MutableSet, @@ -422,12 +526,14 @@ private fun handleQrCode( } catch (e: Exception) { Log.d("QrCodeScanView", "Not a BBQr (single QR): ${e.message}") // single QR code (not BBQr) + triggerHapticFeedback(context) onStateUpdate(QrCodeScannerState.Complete(qrData)) return } // check if it's a BBQr if (!multiQr.isBbqr()) { + triggerHapticFeedback(context) onStateUpdate(QrCodeScannerState.Complete(qrData)) return } @@ -447,6 +553,9 @@ private fun handleQrCode( val result = multiQr.addPart(qr = qrString) val partsLeft = result.partsLeft() + // haptic feedback for each BBQr part scanned + triggerHapticFeedback(context) + onStateUpdate( scanningState.copy( multiQr = multiQr, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt index bd1224d42..34b7246ae 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults 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 @@ -47,6 +48,7 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -89,8 +91,22 @@ fun NewWalletSelectScreen( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var showHardwareWalletSheet by remember { mutableStateOf(false) } + var showNfcHelpSheet by remember { mutableStateOf(false) } + var nfcCalled by remember { mutableStateOf(false) } val context = LocalContext.current + // function to trigger NFC scan and show help after delay (matching iOS behavior) + fun triggerNfcScan() { + onOpenNfcScan() + // after 0.8s delay, show NFC Help button (matching iOS) + nfcCalled = true + } + + // reset nfcCalled when returning to this screen + LaunchedEffect(Unit) { + // initial state - don't reset if coming back from NFC + } + fun importWallet(content: String) { try { val wallet = Wallet.newFromXpub(xpub = content.trim()) @@ -208,9 +224,7 @@ fun NewWalletSelectScreen( contentDescription = "Scan QR", ) } - IconButton(onClick = { - onOpenNfcScan() - }) { + IconButton(onClick = { triggerNfcScan() }) { Icon( painter = painterResource(id = R.drawable.icon_contactless), contentDescription = "NFC", @@ -313,11 +327,34 @@ fun NewWalletSelectScreen( modifier = Modifier.weight(1f), ) } + + // NFC Help button - appears after NFC is called (matching iOS behavior) + if (nfcCalled) { + Text( + text = "NFC Help", + color = Color.White, + modifier = + Modifier + .clickable { showNfcHelpSheet = true } + .padding(vertical = 8.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } } } } } + // NFC Help sheet + if (showNfcHelpSheet) { + ModalBottomSheet( + onDismissRequest = { showNfcHelpSheet = false }, + ) { + NfcHelpSheet() + } + } + if (showHardwareWalletSheet) { ModalBottomSheet( onDismissRequest = { showHardwareWalletSheet = false }, @@ -381,7 +418,7 @@ fun NewWalletSelectScreen( modifier = Modifier.clickable { showHardwareWalletSheet = false - onOpenNfcScan() + triggerNfcScan() }, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NfcHelpSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NfcHelpSheet.kt new file mode 100644 index 000000000..70422aa8a --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NfcHelpSheet.kt @@ -0,0 +1,95 @@ +package org.bitcoinppl.cove.flow.new_wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * NFC Help content showing instructions for ColdCard Q1 and MK4 hardware wallets. + * Matches iOS NfcHelpView.swift + */ +@Composable +fun NfcHelpSheet(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(22.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "How do I import using NFC?", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 18.dp), + ) + + // ColdCard Q1 instructions + Column(modifier = Modifier.padding(bottom = 32.dp)) { + Text( + text = "ColdCard Q1", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + InstructionStep("1. Enable NFC by going to 'Settings' > 'Hardware On/Off' > 'NFC Sharing' ") + InstructionStep("2. Go to 'Advanced / Tools'") + InstructionStep("3. Export Wallet > 'Descriptor' > 'Segwit P2WPKH'") + InstructionStep("4. Press the 'Enter' button, then the 'NFC' button") + InstructionStep("5. Bring the phone to the top of the screen") + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + + // ColdCard MK4 instructions + Column { + Text( + text = "ColdCard MK4", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + InstructionStep("1. Enable NFC by going to 'Settings' > 'Hardware On/Off' > 'NFC Sharing' ") + InstructionStep("2. Go to 'Advanced / Tools'") + InstructionStep("3. Export Wallet > 'Descriptor' > 'Segwit P2WPKH'") + InstructionStep("4. Press the 'Enter' button, then the 'NFC' button") + InstructionStep("5. Bring the phone to the to the coldcard near the 8 button") + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun InstructionStep(text: String) { + Text( + text = text, + fontSize = 16.sp, + lineHeight = 24.sp, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun NfcHelpSheetPreview() { + NfcHelpSheet() +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index e951f1697..fb7e31372 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -11,7 +11,6 @@ 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.fillMaxHeight @@ -21,12 +20,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed +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.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -123,6 +122,7 @@ fun HotWalletVerifyScreen( ) { var actualChipWidth by remember { mutableStateOf(80.dp) } val chipHeight = 46.dp + var showSkipAlert by remember { mutableStateOf(false) } var wordPositions by remember { mutableStateOf(mapOf()) } var targetPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } @@ -241,8 +241,9 @@ fun HotWalletVerifyScreen( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(vertical = 20.dp), - verticalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(24.dp), ) { Column( modifier = @@ -304,43 +305,51 @@ fun HotWalletVerifyScreen( val cellWidth = (maxWidth - 12.dp * 3) / 4 actualChipWidth = cellWidth - LazyVerticalGrid( - columns = GridCells.Fixed(4), - horizontalArrangement = Arrangement.spacedBy(12.dp), + // regular grid layout (not lazy) so we can scroll the whole screen + Column( verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(0.dp), modifier = Modifier.fillMaxWidth(), ) { - itemsIndexed( - options, - key = { idx, value -> "word-$idx-$value" }, - ) { _, word -> - if (animatingWord == word && overlayVisible) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(46.dp), - ) { } - } else { - OptionChip( - text = word, - selected = false, - onClick = { - if (animatingWord == null) { - animatingWord = word + options.chunked(4).forEach { rowItems -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + rowItems.forEach { word -> + Box(modifier = Modifier.weight(1f)) { + if (animatingWord == word && overlayVisible) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(46.dp), + ) { } + } else { + OptionChip( + text = word, + selected = false, + onClick = { + if (animatingWord == null) { + animatingWord = word + } + }, + onPositionCaptured = { position -> + wordPositions = wordPositions + ( + word to + androidx.compose.ui.geometry.Offset( + position.x - rootOffset.x, + position.y - rootOffset.y, + ) + ) + }, + ) } - }, - onPositionCaptured = { position -> - wordPositions = wordPositions + ( - word to - androidx.compose.ui.geometry.Offset( - position.x - rootOffset.x, - position.y - rootOffset.y, - ) - ) - }, - ) + } + } + // fill remaining slots if row is not complete + repeat(4 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } } } } @@ -393,7 +402,7 @@ fun HotWalletVerifyScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { - TextButton(onClick = onSkip) { + TextButton(onClick = { showSkipAlert = true }) { Text( text = stringResource(R.string.btn_skip_verification), color = Color.White.copy(alpha = 0.9f), @@ -403,6 +412,34 @@ fun HotWalletVerifyScreen( } } + // skip verification confirmation dialog + if (showSkipAlert) { + AlertDialog( + onDismissRequest = { showSkipAlert = false }, + title = { Text("Skip verifying words?") }, + text = { + Text( + "Are you sure you want to skip verifying words? Without having a backup of these words, you could lose your bitcoin", + ) + }, + confirmButton = { + TextButton( + onClick = { + showSkipAlert = false + onSkip() + }, + ) { + Text("Yes, Verify Later") + } + }, + dismissButton = { + TextButton(onClick = { showSkipAlert = false }) { + Text("Cancel") + } + }, + ) + } + if (animatingWord != null && overlayVisible) { val word = animatingWord!! val isCorrect = validator.isWordCorrect(word, wordNumber.toUByte()) @@ -469,15 +506,48 @@ private fun OptionChip( }, contentAlignment = Alignment.Center, ) { - Text( + AutoSizeText( text = text, color = textColor, fontWeight = FontWeight.Medium, maxLines = 1, - overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 14.dp), + modifier = Modifier.padding(horizontal = 6.dp), ) } } } + +@Composable +private fun AutoSizeText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, + maxLines: Int = Int.MAX_VALUE, + textAlign: TextAlign? = null, + maxFontSize: Int = 14, + minFontSize: Int = 8, +) { + var fontSize by remember { mutableStateOf(maxFontSize) } + var readyToDraw by remember { mutableStateOf(false) } + + Text( + text = text, + color = color, + fontWeight = fontWeight, + fontSize = fontSize.sp, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + textAlign = textAlign, + softWrap = false, + modifier = modifier, + onTextLayout = { textLayoutResult -> + if (textLayoutResult.hasVisualOverflow && fontSize > minFontSize) { + fontSize -= 1 + } else { + readyToDraw = true + } + }, + ) +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt index f2d7e06ca..91c981e65 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.bitcoinppl.cove.ImportWalletManager import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.CoveTheme import java.util.Locale @@ -88,7 +90,7 @@ fun ImportWalletScreen( ), ) }, - containerColor = MaterialTheme.colorScheme.background, + containerColor = CoveColor.midnightBlue, ) { paddingValues -> Column( modifier = @@ -167,17 +169,17 @@ fun ImportWalletScreen( } }, enabled = !isImporting && words.all { it.isNotBlank() }, - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = CoveColor.btnPrimary, + contentColor = CoveColor.midnightBlue, ), ) { if (isImporting) { CircularProgressIndicator( modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, + color = CoveColor.midnightBlue, ) } else { Text( @@ -204,7 +206,7 @@ private fun EnterWordsWidget( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(10.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 24.dp), ) { @@ -260,7 +262,7 @@ private fun EnterWordWidget( Text( numberLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), ) Spacer(Modifier.width(8.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt new file mode 100644 index 000000000..ccb183285 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -0,0 +1,164 @@ +package org.bitcoinppl.cove.nfc + +import android.app.Activity +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.bitcoinppl.cove.AppAlertState +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove_core.multiFormatTryFromNfcMessage +import org.bitcoinppl.cove_core.nfc.NfcMessage + +private const val TAG = "NfcScanSheet" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NfcScanSheet( + app: AppManager, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val activity = context as? Activity + + var errorMessage by remember { mutableStateOf(null) } + var isScanning by remember { mutableStateOf(false) } + + val nfcReader = + remember(activity) { + activity?.let { NfcReader(it) } + } + + // start scanning when sheet opens + LaunchedEffect(nfcReader) { + if (nfcReader == null) { + errorMessage = "NFC is not available" + return@LaunchedEffect + } + + nfcReader.startScanning() + isScanning = true + } + + // collect scan results + LaunchedEffect(nfcReader) { + nfcReader?.scanResults?.collect { result -> + when (result) { + is NfcScanResult.Success -> { + isScanning = false + app.sheetState = null + + try { + // create NfcMessage from scanned data + val nfcMessage = + NfcMessage.tryNew( + string = result.text, + data = result.data, + ) + + // convert to MultiFormat and handle + val multiFormat = multiFormatTryFromNfcMessage(nfcMessage) + app.handleMultiFormat(multiFormat) + } catch (e: Exception) { + Log.e(TAG, "Failed to process NFC data: ${e.message}", e) + app.alertState = + TaggedItem( + AppAlertState.InvalidFormat(e.message ?: "Failed to process NFC data"), + ) + } + } + is NfcScanResult.Error -> { + isScanning = false + errorMessage = result.message + } + } + } + } + + // cleanup when dismissed + DisposableEffect(nfcReader) { + onDispose { + nfcReader?.stopScanning() + } + } + + ModalBottomSheet( + onDismissRequest = { + nfcReader?.stopScanning() + onDismiss() + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (errorMessage != null) { + Text( + text = "Error", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage!!, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { + errorMessage = null + nfcReader?.startScanning() + isScanning = true + }) { + Text("Try Again") + } + } else { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Ready to Scan", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = nfcReader?.message ?: "Hold your phone near the NFC tag", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt index b9001f9e0..7f9a7d624 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/send_confirmation/SendConfirmationScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -191,11 +192,14 @@ private fun BalanceWidget( isHidden: Boolean, onToggleVisibility: () -> Unit, ) { + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val balanceHeight = (screenHeight * 0.18f).coerceIn(100.dp, 160.dp) + Box( modifier = Modifier .fillMaxWidth() - .height(160.dp) + .height(balanceHeight) .padding(horizontal = 16.dp, vertical = 20.dp), ) { Row( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index a59a5c356..8a70a5d27 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -106,44 +106,6 @@ fun SidebarContainer( Box( modifier = Modifier.fillMaxSize(), ) { - // backdrop overlay - if (openPercentage > 0f) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.45f * openPercentage)) - .pointerInput(sidebarWidthPx) { - detectTapGestures { offset -> - // only close if tap is outside the sidebar area (to the right of it) - if (offset.x > sidebarWidthPx) { - app.isSidebarVisible = false - } - } - }.pointerInput(Unit) { - var totalDrag = 0f - detectHorizontalDragGestures( - onDragStart = { - totalDrag = 0f - }, - onDragEnd = { - // only close if swipe distance exceeded minimum threshold - if (totalDrag < -20f) { - app.isSidebarVisible = false - } - totalDrag = 0f - }, - onDragCancel = { - totalDrag = 0f - }, - onHorizontalDrag = { _, dragAmount -> - totalDrag += dragAmount - }, - ) - }, - ) - } - // main content Box( modifier = @@ -168,7 +130,7 @@ fun SidebarContainer( }, onDragEnd = { if (isDragging && isValidDrag) { - val threshold = sidebarWidthPx * 0.3f + val threshold = sidebarWidthPx * 0.15f val finalOffset = targetOffset + gestureOffset val shouldBeOpen = finalOffset > threshold val currentDragPosition = finalOffset.coerceIn(0f, sidebarWidthPx) @@ -234,6 +196,44 @@ fun SidebarContainer( content() } + // backdrop overlay - rendered after main content so it intercepts taps + if (openPercentage > 0f) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f * openPercentage)) + .pointerInput(sidebarWidthPx) { + detectTapGestures { offset -> + // only close if tap is outside the sidebar area (to the right of it) + if (offset.x > sidebarWidthPx) { + app.isSidebarVisible = false + } + } + }.pointerInput(Unit) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragStart = { + totalDrag = 0f + }, + onDragEnd = { + // only close if swipe distance exceeded minimum threshold + if (totalDrag < -10f) { + app.isSidebarVisible = false + } + totalDrag = 0f + }, + onDragCancel = { + totalDrag = 0f + }, + onHorizontalDrag = { _, dragAmount -> + totalDrag += dragAmount + }, + ) + }, + ) + } + // sidebar view if (openPercentage > 0f || app.isSidebarVisible) { Box( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index ae3fa5ea0..2986f5e99 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -81,9 +81,9 @@ fun SidebarView( IconButton( onClick = { - app.isSidebarVisible = false - // TODO: implement NFC scanning - // app.nfcReader.scan() + app.closeSidebarAndNavigate { + app.scanNfc() + } }, ) { Icon( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 5390ecfd1..f61a57588 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -276,7 +276,7 @@ fun TransactionDetailsScreen( } // poll for confirmations if not fully confirmed - LaunchedEffect(transactionDetails, isPreview) { + LaunchedEffect(transactionDetails.txId(), isPreview) { if (isPreview || manager == null) return@LaunchedEffect if (!transactionDetails.isConfirmed()) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utils/WalletColorExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/utils/WalletColorExt.kt index 09a462d05..f59594590 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/utils/WalletColorExt.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/utils/WalletColorExt.kt @@ -34,3 +34,10 @@ fun WalletColor.toComposeColor(): Color = is WalletColor.WPastelTeal -> Color(0xFFB2DFDB) is WalletColor.WLightPastelYellow -> Color(0xFFFFFDE7) } + +fun WalletColor.isLightColor(): Boolean = + when (this) { + is WalletColor.WAlmostWhite -> true + is WalletColor.WLightMint -> true + else -> false + } 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 c81cd7b32..1d17a4593 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 @@ -5,8 +5,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -325,3 +327,35 @@ fun SwitchRow( ) } } + +// SwiftUI-compatible VStack with default 8dp spacing between children +@Composable +fun VStack( + modifier: Modifier = Modifier, + spacing: Dp = 8.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = if (spacing > 0.dp) Arrangement.spacedBy(spacing) else Arrangement.Top, + horizontalAlignment = horizontalAlignment, + content = content, + ) +} + +// SwiftUI-compatible HStack with default 10dp spacing between children +@Composable +fun HStack( + modifier: Modifier = Modifier, + spacing: Dp = 10.dp, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier, + horizontalArrangement = if (spacing > 0.dp) Arrangement.spacedBy(spacing) else Arrangement.Start, + verticalAlignment = verticalAlignment, + content = content, + ) +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt index 66bb4ea90..85d40a302 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.ui.theme.CoveColor @OptIn(ExperimentalFoundationApi::class) @@ -134,14 +134,14 @@ fun RecoveryWordChip( color = CoveColor.midnightBlue, ) Spacer(Modifier.width(8.dp)) - Text( + AutoSizeText( text = word, color = CoveColor.midnightBlue, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + maxFontSize = 14.sp, + minimumScaleFactor = 0.7f, ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/WalletIcon.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/WalletIcon.kt index ebb99d0de..77db4c086 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/WalletIcon.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/WalletIcon.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.bitcoinppl.cove.utils.isLightColor import org.bitcoinppl.cove.utils.toComposeColor import org.bitcoinppl.cove_core.WalletColor import org.bitcoinppl.cove_core.WalletMetadata @@ -16,6 +17,7 @@ fun WalletIcon( size: Dp = 40.dp, cornerRadius: Dp = 8.dp, ) { + val iconTint = if (wallet.color.isLightColor()) Color.Black else Color.White RoundRectImage( size = size, backgroundColor = wallet.color.toComposeColor(), @@ -24,7 +26,7 @@ fun WalletIcon( .rememberVectorPainter(Icons.Default.AccountBalanceWallet), contentDescription = null, cornerRadius = cornerRadius, - imageTint = Color.White, + imageTint = iconTint, ) } @@ -34,6 +36,7 @@ fun WalletIcon( size: Dp = 40.dp, cornerRadius: Dp = 8.dp, ) { + val iconTint = if (walletColor.isLightColor()) Color.Black else Color.White RoundRectImage( size = size, backgroundColor = walletColor.toComposeColor(), @@ -42,6 +45,6 @@ fun WalletIcon( .rememberVectorPainter(Icons.Default.AccountBalanceWallet), contentDescription = null, cornerRadius = cornerRadius, - imageTint = Color.White, + imageTint = iconTint, ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 8060c3449..014899b03 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.SouthWest import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -46,6 +47,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -64,9 +67,12 @@ import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.HotWalletRoute +import org.bitcoinppl.cove_core.NewWalletRoute import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.Transaction import org.bitcoinppl.cove_core.types.TransactionDirection +import org.bitcoinppl.cove_core.types.WalletId import java.text.NumberFormat import java.util.Locale @@ -280,112 +286,124 @@ fun WalletTransactionsScreen( Modifier .fillMaxWidth() .weight(1f) // Fill remaining space - .background(listBg) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .background(listBg), ) { - Text( - text = stringResource(R.string.title_transactions), - color = secondaryText, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, + VerifyReminder( + walletId = manager?.walletMetadata?.id ?: "", + isVerified = manager?.isVerified ?: true, + app = app, ) - // render real transactions or show empty state - if (transactions.isEmpty()) { - // empty state - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.no_transactions_yet), - color = secondaryText, - fontSize = 14.sp, - ) - } - } else { - // render transactions dynamically in scrollable list - LazyColumn( - state = listState, - modifier = Modifier.weight(1f), - ) { - itemsIndexed(transactions) { index, txn -> - when (txn) { - is Transaction.Confirmed -> { - val direction = txn.v1.sentAndReceived().direction() - val txType = - when (direction) { - TransactionDirection.INCOMING -> TransactionType.RECEIVED - TransactionDirection.OUTGOING -> TransactionType.SENT - } - - // format amount with manager if available - val formattedAmount = - manager?.let { - val amount = txn.v1.sentAndReceived().amount() - val prefix = if (direction == TransactionDirection.OUTGOING) "-" else "" - prefix + it.displayAmount(amount, showUnit = true) - } ?: txn.v1.sentAndReceived().label() - - TransactionWidget( - type = txType, - date = txn.v1.confirmedAtFmt(), - amount = formattedAmount, - balanceAfter = txn.v1.blockHeightFmt(), - listCard = listCard, - primaryText = primaryText, - secondaryText = secondaryText, - transaction = txn, - app = app, - manager = manager, - ) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.title_transactions), + color = secondaryText, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + + // render real transactions or show empty state + if (transactions.isEmpty()) { + // empty state + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_transactions_yet), + color = secondaryText, + fontSize = 14.sp, + ) + } + } else { + // render transactions dynamically in scrollable list + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + ) { + itemsIndexed(transactions) { index, txn -> + when (txn) { + is Transaction.Confirmed -> { + val direction = txn.v1.sentAndReceived().direction() + val txType = + when (direction) { + TransactionDirection.INCOMING -> TransactionType.RECEIVED + TransactionDirection.OUTGOING -> TransactionType.SENT + } + + // format amount with manager if available + val formattedAmount = + manager?.let { + val amount = txn.v1.sentAndReceived().amount() + val prefix = if (direction == TransactionDirection.OUTGOING) "-" else "" + prefix + it.displayAmount(amount, showUnit = true) + } ?: txn.v1.sentAndReceived().label() + + TransactionWidget( + type = txType, + date = txn.v1.confirmedAtFmt(), + amount = formattedAmount, + balanceAfter = txn.v1.blockHeightFmt(), + listCard = listCard, + primaryText = primaryText, + secondaryText = secondaryText, + transaction = txn, + app = app, + manager = manager, + ) + } + + is Transaction.Unconfirmed -> { + val direction = txn.v1.sentAndReceived().direction() + val txType = + when (direction) { + TransactionDirection.INCOMING -> TransactionType.RECEIVED + TransactionDirection.OUTGOING -> TransactionType.SENT + } + + // format amount with manager if available + val formattedAmount = + manager?.let { + val amount = txn.v1.sentAndReceived().amount() + val prefix = if (direction == TransactionDirection.OUTGOING) "-" else "" + prefix + it.displayAmount(amount, showUnit = true) + } ?: txn.v1.sentAndReceived().label() + + TransactionWidget( + type = txType, + date = stringResource(R.string.pending), + amount = formattedAmount, + balanceAfter = stringResource(R.string.unconfirmed), + listCard = listCard, + primaryText = primaryText, + secondaryText = secondaryText, + transaction = txn, + app = app, + manager = manager, + ) + } } - is Transaction.Unconfirmed -> { - val direction = txn.v1.sentAndReceived().direction() - val txType = - when (direction) { - TransactionDirection.INCOMING -> TransactionType.RECEIVED - TransactionDirection.OUTGOING -> TransactionType.SENT - } - - // format amount with manager if available - val formattedAmount = - manager?.let { - val amount = txn.v1.sentAndReceived().amount() - val prefix = if (direction == TransactionDirection.OUTGOING) "-" else "" - prefix + it.displayAmount(amount, showUnit = true) - } ?: txn.v1.sentAndReceived().label() - - TransactionWidget( - type = txType, - date = stringResource(R.string.pending), - amount = formattedAmount, - balanceAfter = stringResource(R.string.unconfirmed), - listCard = listCard, - primaryText = primaryText, - secondaryText = secondaryText, - transaction = txn, - app = app, - manager = manager, - ) + // add divider between transactions (but not after the last one) + if (index < transactions.size - 1) { + HorizontalDivider(color = dividerColor, thickness = 0.5.dp) } } - // add divider between transactions (but not after the last one) - if (index < transactions.size - 1) { - HorizontalDivider(color = dividerColor, thickness = 0.5.dp) + // add bottom spacing + item { + Spacer(Modifier.height(12.dp)) } } - - // add bottom spacing - item { - Spacer(Modifier.height(12.dp)) - } } } } @@ -544,3 +562,61 @@ private fun BalanceWidget( } } } + +@Composable +private fun VerifyReminder( + walletId: WalletId, + isVerified: Boolean, + app: AppManager?, +) { + if (!isVerified) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clickable { + app?.pushRoute( + Route.NewWallet( + NewWalletRoute.HotWallet( + HotWalletRoute.VerifyWords(walletId), + ), + ), + ) + }.background( + brush = + Brush.linearGradient( + colors = + listOf( + Color(0xFFFF9800).copy(alpha = 0.67f), + Color(0xFFFFEB3B).copy(alpha = 0.96f), + ), + start = Offset.Zero, + end = Offset.Infinite, + ), + ).padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color.Red.copy(alpha = 0.85f), + ) + Text( + text = "backup your wallet", + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = Color.Black.copy(alpha = 0.66f), + ) + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color.Red.copy(alpha = 0.85f), + ) + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cb..39f8cfec1 100644 --- a/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,166 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d114..5635c8577 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -4,27 +4,117 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From b7f96951c8d5d72010416593c939cb2b284d8861 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 15:52:47 -0600 Subject: [PATCH 13/97] Fix UTXO sort buttons and continue button state - Update SortRow to derive button state dynamically from Rust - Add sort state read to trigger Compose recomposition - Make sort property public with private set in CoinControlManager - Add enabled parameter to Continue button - Remove hardcoded arrow directions - now derived from ListSortDirection --- .../org/bitcoinppl/cove/CoinControlManager.kt | 3 +- .../cove/utxo_list/UtxoListScreen.kt | 52 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt index 93ee29605..cb1f79081 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt @@ -32,9 +32,10 @@ class CoinControlManager( private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val isClosed = AtomicBoolean(false) - private var sort by mutableStateOf( + var sort by mutableStateOf( CoinControlListSort.Date(ListSortDirection.DESCENDING), ) + private set var search by mutableStateOf("") diff --git a/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt index d061352a6..5304bb126 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/utxo_list/UtxoListScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.ImageButton import java.text.SimpleDateFormat import java.util.Locale @@ -98,21 +99,10 @@ fun UtxoListScreen( manager.selected.map { it.hashToUint() }.toSet() } - val currentSort = - listOf( - org.bitcoinppl.cove_core.CoinControlListSortKey.DATE to UtxoSort.DATE, - org.bitcoinppl.cove_core.CoinControlListSortKey.NAME to UtxoSort.NAME, - org.bitcoinppl.cove_core.CoinControlListSortKey.AMOUNT to UtxoSort.AMOUNT, - org.bitcoinppl.cove_core.CoinControlListSortKey.CHANGE to UtxoSort.CHANGE, - ).firstOrNull { (key, _) -> - manager.rust.buttonPresentation(key) is org.bitcoinppl.cove_core.ButtonPresentation.Selected - }?.second ?: UtxoSort.DATE - UtxoListScreenContent( manager = manager, utxos = manager.utxos, selected = selected, - currentSort = currentSort, totalSelectedAmount = manager.totalSelectedAmount, searchQuery = manager.search, onBack = { app.popRoute() }, @@ -163,7 +153,6 @@ private fun UtxoListScreenContent( manager: org.bitcoinppl.cove.CoinControlManager, utxos: List, selected: Set, - currentSort: UtxoSort, totalSelectedAmount: String, searchQuery: String, onBack: () -> Unit, @@ -273,7 +262,7 @@ private fun UtxoListScreenContent( ) Spacer(modifier = Modifier.height(16.dp)) SortRow( - currentSort = currentSort, + manager = manager, onSortChange = onSortChange, ) } @@ -407,6 +396,7 @@ private fun UtxoListScreenContent( stringResource(R.string.continue_button) }, onClick = onContinue, + enabled = anySelected, colors = ButtonDefaults.buttonColors( containerColor = if (anySelected) CoveColor.midnightBlue else MaterialTheme.colorScheme.surfaceVariant, @@ -515,36 +505,49 @@ private fun ChangeBadge(tintColor: Color = CoveColor.WarningOrange) { @Composable private fun SortRow( - currentSort: UtxoSort, + manager: org.bitcoinppl.cove.CoinControlManager, onSortChange: (UtxoSort) -> Unit, ) { + // read sort state to trigger recomposition when it changes (same pattern as iOS: _ = self.sort) + @Suppress("UNUSED_VARIABLE") + val sort = manager.sort + + val datePresentation = manager.rust.buttonPresentation(org.bitcoinppl.cove_core.CoinControlListSortKey.DATE) + val namePresentation = manager.rust.buttonPresentation(org.bitcoinppl.cove_core.CoinControlListSortKey.NAME) + val amountPresentation = manager.rust.buttonPresentation(org.bitcoinppl.cove_core.CoinControlListSortKey.AMOUNT) + val changePresentation = manager.rust.buttonPresentation(org.bitcoinppl.cove_core.CoinControlListSortKey.CHANGE) + Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { SortChip( label = stringResource(R.string.sort_date), - selected = currentSort == UtxoSort.DATE, + selected = datePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, onClick = { onSortChange(UtxoSort.DATE) }, - showArrow = true, - arrowUp = false, + showArrow = datePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, + arrowUp = (datePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING, ) SortChip( label = stringResource(R.string.sort_name), - selected = currentSort == UtxoSort.NAME, + selected = namePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, onClick = { onSortChange(UtxoSort.NAME) }, + showArrow = namePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, + arrowUp = (namePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING, ) SortChip( label = stringResource(R.string.sort_amount), - selected = currentSort == UtxoSort.AMOUNT, + selected = amountPresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, onClick = { onSortChange(UtxoSort.AMOUNT) }, - showArrow = true, - arrowUp = true, + showArrow = amountPresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, + arrowUp = (amountPresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING, ) SortChip( label = stringResource(R.string.sort_change), - selected = currentSort == UtxoSort.CHANGE, + selected = changePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, onClick = { onSortChange(UtxoSort.CHANGE) }, + showArrow = changePresentation is org.bitcoinppl.cove_core.ButtonPresentation.Selected, + arrowUp = (changePresentation as? org.bitcoinppl.cove_core.ButtonPresentation.Selected)?.v1 == org.bitcoinppl.cove_core.ListSortDirection.ASCENDING, ) } } @@ -572,9 +575,10 @@ private fun SortChip( ), verticalAlignment = Alignment.CenterVertically, ) { - Text( + AutoSizeText( text = label, - fontSize = 14.sp, + maxFontSize = 14.sp, + minimumScaleFactor = 0.01f, color = txt, fontWeight = FontWeight.Medium, ) From 0d61edf0015266b27a3b76c68f2754035c6b9702 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 15:54:30 -0600 Subject: [PATCH 14/97] Create BitcoinShieldIcon.kt --- .../cove/views/BitcoinShieldIcon.kt | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt new file mode 100644 index 000000000..b1ac910cd --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt @@ -0,0 +1,126 @@ +package org.bitcoinppl.cove.views + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun BitcoinShieldIcon( + modifier: Modifier = Modifier, + size: Dp = 13.dp, + color: Color = Color.White, +) { + Canvas(modifier = modifier.size(size)) { + val scaleFactor = size.toPx() / 103f + scale(scaleFactor, scaleFactor) { + drawShield(color) + drawBitcoin(color) + } + } +} + +private fun DrawScope.drawShield(color: Color) { + val path = Path().apply { + // outer shield + moveTo(51.625f, 124.688f) + cubicTo(50.5f, 124.688f, 48.875f, 124.188f, 47.5f, 123.375f) + cubicTo(12.625f, 102.938f, 0.937f, 95.5f, 0.937f, 74f) + lineTo(0.937f, 26.5f) + cubicTo(0.937f, 19.812f, 3.375f, 17.562f, 9.125f, 15.312f) + cubicTo(18f, 11.813f, 37.813f, 4.25f, 46.688f, 1.376f) + cubicTo(48.312f, 0.876f, 49.938f, 0.438f, 51.625f, 0.438f) + cubicTo(53.313f, 0.438f, 54.938f, 0.814f, 56.625f, 1.376f) + cubicTo(65.5f, 4.438f, 85.25f, 11.75f, 94.125f, 15.313f) + cubicTo(99.875f, 17.625f, 102.312f, 19.813f, 102.312f, 26.5f) + lineTo(102.312f, 74f) + cubicTo(102.312f, 95.5f, 90.812f, 103.125f, 55.812f, 123.375f) + cubicTo(54.375f, 124.188f, 52.812f, 124.688f, 51.625f, 124.688f) + close() + + // inner shield cutout + moveTo(51.625f, 116.125f) + cubicTo(52.688f, 116.125f, 53.813f, 115.625f, 55.25f, 114.688f) + cubicTo(84.188f, 96.562f, 94.688f, 91.5f, 94.688f, 72.5f) + lineTo(94.688f, 27.937f) + cubicTo(94.688f, 24.812f, 94.125f, 23.625f, 91.75f, 22.75f) + cubicTo(83.312f, 19.687f, 62.937f, 12.125f, 54.625f, 8.875f) + cubicTo(53.437f, 8.437f, 52.375f, 8.125f, 51.625f, 8.125f) + cubicTo(50.875f, 8.125f, 49.812f, 8.375f, 48.625f, 8.875f) + cubicTo(40.312f, 12.125f, 19.875f, 19.438f, 11.5f, 22.75f) + cubicTo(9.187f, 23.688f, 8.562f, 24.813f, 8.562f, 27.938f) + lineTo(8.562f, 72.5f) + cubicTo(8.563f, 91.5f, 19f, 96.688f, 48f, 114.688f) + cubicTo(49.438f, 115.625f, 50.625f, 116.125f, 51.625f, 116.125f) + close() + } + drawPath(path, color) +} + +private fun DrawScope.drawBitcoin(color: Color) { + val path = Path().apply { + // main bitcoin B shape + moveTo(40.22f, 85.453f) + cubicTo(37.818f, 85.453f, 36.47f, 83.813f, 36.47f, 81.703f) + lineTo(36.47f, 37.728f) + cubicTo(36.47f, 35.502f, 37.965f, 33.978f, 40.22f, 33.978f) + lineTo(43.62f, 33.978f) + lineTo(43.62f, 28.324f) + cubicTo(43.62f, 27.211f, 44.41f, 26.42f, 45.553f, 26.42f) + cubicTo(46.695f, 26.42f, 47.486f, 27.21f, 47.486f, 28.324f) + lineTo(47.486f, 33.978f) + lineTo(52.994f, 33.978f) + lineTo(52.994f, 28.324f) + cubicTo(52.994f, 27.211f, 53.814f, 26.42f, 54.957f, 26.42f) + cubicTo(56.041f, 26.42f, 56.832f, 27.21f, 56.832f, 28.324f) + lineTo(56.832f, 34.096f) + cubicTo(64.244f, 34.886f, 69.693f, 39.369f, 69.693f, 46.723f) + cubicTo(69.693f, 52.26f, 65.709f, 57.182f, 60.231f, 58.148f) + lineTo(60.231f, 58.471f) + cubicTo(67.496f, 59.321f, 72.359f, 64.271f, 72.359f, 71.186f) + cubicTo(72.359f, 80.268f, 65.621f, 84.721f, 56.832f, 85.366f) + lineTo(56.832f, 91.313f) + cubicTo(56.832f, 92.426f, 56.041f, 93.246f, 54.957f, 93.246f) + cubicTo(53.815f, 93.246f, 52.994f, 92.426f, 52.994f, 91.313f) + lineTo(52.994f, 85.453f) + lineTo(47.486f, 85.453f) + lineTo(47.486f, 91.313f) + cubicTo(47.486f, 92.426f, 46.696f, 93.246f, 45.553f, 93.246f) + cubicTo(44.41f, 93.246f, 43.619f, 92.426f, 43.619f, 91.313f) + lineTo(43.619f, 85.453f) + lineTo(40.221f, 85.453f) + close() + + // upper B bump + moveTo(42.273f, 56.391f) + lineTo(51.5f, 56.391f) + cubicTo(58.121f, 56.391f, 63.834f, 53.988f, 63.834f, 47.367f) + cubicTo(63.834f, 41.537f, 59.117f, 39.047f, 53.141f, 39.047f) + lineTo(42.27f, 39.047f) + lineTo(42.27f, 56.39f) + close() + + // lower B bump + moveTo(42.27f, 80.384f) + lineTo(53.607f, 80.384f) + cubicTo(60.667f, 80.384f, 66.439f, 77.864f, 66.439f, 70.892f) + cubicTo(66.439f, 63.714f, 60.169f, 61.429f, 52.933f, 61.429f) + lineTo(42.272f, 61.429f) + lineTo(42.272f, 80.384f) + close() + } + drawPath(path, color) +} + +@Preview(showBackground = true, backgroundColor = 0xFF1A1A2E) +@Composable +private fun BitcoinShieldIconPreview() { + BitcoinShieldIcon(size = 50.dp, color = Color.White) +} From 14482c58eb318290635c9c997c9c83a4aef246e1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 15:54:44 -0600 Subject: [PATCH 15/97] Update ic_launcher_foreground.xml --- .../app/src/main/res/drawable/ic_launcher_foreground.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 5635c8577..411afc1d6 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -7,10 +7,10 @@ + android:translateX="30" + android:translateY="30" + android:scaleX="0.066" + android:scaleY="0.066"> From df7e296de685679335c8e27365dbee5884104e0e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 15:55:44 -0600 Subject: [PATCH 16/97] Alert fixes, AutoSize fixes, other random UI fixes --- .../java/org/bitcoinppl/cove/AppAlertState.kt | 50 +++ .../java/org/bitcoinppl/cove/MainActivity.kt | 285 +++++++++++++++++- .../org/bitcoinppl/cove/QrCodeScanView.kt | 39 ++- .../cove/SelectedWalletContainer.kt | 5 + .../hot_wallet/HotWalletCreateScreen.kt | 11 +- .../hot_wallet/HotWalletImportScreen.kt | 228 +++++++------- .../hot_wallet/HotWalletVerifyScreen.kt | 38 +-- .../cove/import_wallet/ImportWalletScreen.kt | 22 +- .../cove/settings/WalletSettingsScreen.kt | 7 +- .../bitcoinppl/cove/sidebar/SidebarView.kt | 2 +- .../TransactionDetailsScreen.kt | 15 +- .../bitcoinppl/cove/views/RecoveryWords.kt | 6 +- .../WalletTransactionsScreen.kt | 69 ++++- android/app/src/main/res/values/strings.xml | 1 + .../HotWallet/HotWalletCreateScreen.swift | 2 +- 15 files changed, 589 insertions(+), 191 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt index 890b8a3c8..0cbf04e0b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt @@ -131,4 +131,54 @@ sealed class AppAlertState { is TapSignerNoBackup -> "No Backup Found" is General -> title } + + fun message(): String = + when (this) { + is InvalidWordGroup -> "The words do not create a valid wallet. Please check the words and try again." + is DuplicateWallet -> "This wallet has already been imported! Taking you there now..." + is ErrorImportingHotWallet -> message + is ImportedSuccessfully -> "Wallet imported successfully" + is ImportedLabelsSuccessfully -> "Labels imported successfully" + is UnableToSelectWallet -> "Unable to select wallet, please try again" + is ErrorImportingHardwareWallet -> message + is InvalidFileFormat -> message + is InvalidFormat -> message + is AddressWrongNetwork -> "This address is for ${network.name}, but you're on ${currentNetwork.name}" + is NoWalletSelected -> "Please select a wallet to send to this address" + is FoundAddress -> "Address: ${address.spacedOut()}" + is NoCameraPermission -> "Please enable camera access in settings to scan QR codes" + is FailedToScanQr -> error + is NoUnsignedTransactionFound -> "No unsigned transaction found for this transaction" + is UnableToGetAddress -> error + is CantSendOnWatchOnlyWallet -> "You cannot send from a watch-only wallet" + is UninitializedTapSigner -> "This TAPSIGNER needs to be set up before it can be used" + is TapSignerSetupFailed -> message + is TapSignerDeriveFailed -> message + is TapSignerInvalidAuth -> "The PIN you entered is incorrect" + is TapSignerWalletFound -> "A wallet for this TAPSIGNER was found" + is InitializedTapSigner -> "Would you like to import this TAPSIGNER?" + is TapSignerNoBackup -> "No backup found for this TAPSIGNER" + is General -> message + } + + fun isSnackbar(): Boolean = + when (this) { + is ImportedSuccessfully, + is ImportedLabelsSuccessfully, + is InvalidWordGroup, + is ErrorImportingHotWallet, + is UnableToSelectWallet, + is ErrorImportingHardwareWallet, + is InvalidFileFormat, + is InvalidFormat, + is NoUnsignedTransactionFound, + is UnableToGetAddress, + is FailedToScanQr, + is CantSendOnWatchOnlyWallet, + is TapSignerSetupFailed, + is TapSignerDeriveFailed, + is TapSignerInvalidAuth, + -> true + else -> false + } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 04a7aa269..aba15e2b1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -1,6 +1,9 @@ package org.bitcoinppl.cove +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.Settings import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -12,11 +15,17 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,6 +36,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.NfcScanSheet @@ -34,6 +46,8 @@ import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer import org.bitcoinppl.cove.ui.theme.CoveTheme import org.bitcoinppl.cove.views.LockView +import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.TapSignerRoute import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat import org.bitcoinppl.cove_core.types.ColorSchemeSelection @@ -98,23 +112,35 @@ class MainActivity : ComponentActivity() { } } app.asyncRuntimeReady -> { - Box { - LockView { - SidebarContainer(app = app) { - // NavDisplay handles transitions and back gestures - // key resets view when network/routeId changes - key(app.selectedNetwork, app.routeId) { - CoveNavDisplay(app = app) + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + LockView { + SidebarContainer(app = app) { + // NavDisplay handles transitions and back gestures + // key resets view when network/routeId changes + key(app.selectedNetwork, app.routeId) { + CoveNavDisplay(app = app) + } } } - } - // global sheet rendering - app.sheetState?.let { taggedState -> - SheetContent( - state = taggedState, + // global sheet rendering + app.sheetState?.let { taggedState -> + SheetContent( + state = taggedState, + app = app, + onDismiss = { app.sheetState = null }, + ) + } + + // global alert rendering + GlobalAlertHandler( app = app, - onDismiss = { app.sheetState = null }, + snackbarHostState = snackbarHostState, ) } } @@ -181,3 +207,236 @@ private fun SheetContent( } } } + +@Composable +private fun GlobalAlertHandler( + app: AppManager, + snackbarHostState: SnackbarHostState, +) { + val alertState = app.alertState ?: return + val state = alertState.item + + if (state.isSnackbar()) { + LaunchedEffect(alertState.id) { + snackbarHostState.showSnackbar( + message = state.message(), + duration = SnackbarDuration.Short, + ) + app.alertState = null + } + } else { + GlobalAlertDialog( + alert = alertState, + app = app, + onDismiss = { app.alertState = null }, + ) + } +} + +@Composable +private fun GlobalAlertDialog( + alert: TaggedItem, + app: AppManager, + onDismiss: () -> Unit, +) { + when (val state = alert.item) { + is AppAlertState.DuplicateWallet -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + try { + app.rust.selectWallet(state.walletId) + app.resetRoute(Route.SelectedWallet(state.walletId)) + } catch (e: Exception) { + Log.e("GlobalAlert", "Failed to select wallet", e) + } + }) { Text("OK") } + }, + ) + } + + is AppAlertState.NoCameraPermission -> { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + }) { Text("Open Settings") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.AddressWrongNetwork -> { + val clipboardManager = LocalClipboardManager.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(state.address.string())) + onDismiss() + }) { Text("Copy Address") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.FoundAddress -> { + val clipboardManager = LocalClipboardManager.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(state.address.string())) + onDismiss() + }) { Text("Copy Address") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.NoWalletSelected -> { + val clipboardManager = LocalClipboardManager.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(state.address.string())) + onDismiss() + }) { Text("Copy Address") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.UninitializedTapSigner -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + app.sheetState = + TaggedItem( + AppSheetState.TapSigner(TapSignerRoute.InitSelect(state.tapSigner)), + ) + }) { Text("Yes") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.TapSignerWalletFound -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + try { + app.rust.selectWallet(state.walletId) + app.resetRoute(Route.SelectedWallet(state.walletId)) + } catch (e: Exception) { + Log.e("GlobalAlert", "Failed to select wallet", e) + } + }) { Text("Yes") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.InitializedTapSigner -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + app.sheetState = + TaggedItem( + AppSheetState.TapSigner(TapSignerRoute.InitSelect(state.tapSigner)), + ) + }) { Text("Yes") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.TapSignerNoBackup -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + app.sheetState = + TaggedItem( + AppSheetState.TapSigner(TapSignerRoute.InitSelect(state.tapSigner)), + ) + }) { Text("Yes") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) + } + + is AppAlertState.General -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + ) + } + + else -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index 3a1ffdf79..6eb035475 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -14,6 +14,7 @@ import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -22,11 +23,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn -import androidx.compose.material.icons.outlined.CropFree import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -227,7 +228,7 @@ private fun QrScannerContent( // flashlight and zoom state var isFlashOn by remember { mutableStateOf(false) } - var zoomLevel by remember { mutableStateOf(1.0f) } // 1.0 = 1x, 2.0 = 2x + var zoomLevel by remember { mutableStateOf(1.2f) } // 1.2 = "1x", 2.0 = 2x // toggle flashlight fun toggleFlash() { @@ -237,10 +238,10 @@ private fun QrScannerContent( } } - // toggle zoom between 1x and 2x + // toggle zoom between 1x (1.2f) and 2x fun toggleZoom() { cameraRef.value?.let { camera -> - zoomLevel = if (zoomLevel == 1.0f) 2.0f else 1.0f + zoomLevel = if (zoomLevel == 1.2f) 2.0f else 1.2f camera.cameraControl.setZoomRatio(zoomLevel) } } @@ -325,6 +326,7 @@ private fun QrScannerContent( imageAnalysis, ) cameraRef.value = camera + camera.cameraControl.setZoomRatio(zoomLevel) } catch (e: Exception) { Log.e("QrCodeScanView", "Camera binding failed", e) onDismiss() @@ -380,7 +382,7 @@ private fun QrScannerContent( contentAlignment = Alignment.Center, ) { Text( - text = if (zoomLevel == 1.0f) "1x" else "2x", + text = if (zoomLevel == 1.2f) "1x" else "2x", color = Color.White, fontWeight = FontWeight.Medium, ) @@ -388,15 +390,32 @@ private fun QrScannerContent( } // viewfinder overlay - centered - Icon( - imageVector = Icons.Outlined.CropFree, - contentDescription = "Scan area", + Canvas( modifier = Modifier .size(200.dp) .align(Alignment.Center), - tint = Color.White.copy(alpha = 0.7f), - ) + ) { + val strokeWidth = 4.dp.toPx() + val cornerLength = 40.dp.toPx() + val color = Color.White.copy(alpha = 0.7f) + + // top-left corner + drawLine(color, Offset(0f, cornerLength), Offset(0f, 0f), strokeWidth) + drawLine(color, Offset(0f, 0f), Offset(cornerLength, 0f), strokeWidth) + + // top-right corner + drawLine(color, Offset(size.width - cornerLength, 0f), Offset(size.width, 0f), strokeWidth) + drawLine(color, Offset(size.width, 0f), Offset(size.width, cornerLength), strokeWidth) + + // bottom-left corner + drawLine(color, Offset(0f, size.height - cornerLength), Offset(0f, size.height), strokeWidth) + drawLine(color, Offset(0f, size.height), Offset(cornerLength, size.height), strokeWidth) + + // bottom-right corner + drawLine(color, Offset(size.width - cornerLength, size.height), Offset(size.width, size.height), strokeWidth) + drawLine(color, Offset(size.width, size.height - cornerLength), Offset(size.width, size.height), strokeWidth) + } // overlay content Column( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt index e04264726..aaac23b84 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -108,9 +109,13 @@ fun SelectedWalletContainer( try { // small delay and then start scanning wallet delay(WALLET_SCAN_DELAY_MS) + if (!isActive) return@LaunchedEffect wm.rust.getTransactions() wm.updateWalletBalance() wm.rust.startWalletScan() + } catch (e: CancellationException) { + // composable left composition, this is expected + throw e } catch (e: Exception) { android.util.Log.e(tag, "wallet scan failed: ${e.message}", e) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt index 564a3767d..c617c0c57 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletCreateScreen.kt @@ -56,6 +56,7 @@ import org.bitcoinppl.cove.PendingWalletManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.utils.intoRoute +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.DotsIndicator import org.bitcoinppl.cove.views.ImageButton @@ -364,18 +365,20 @@ private fun WordCardView( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( + AutoSizeText( text = "${groupedWord.number}.", color = Color.Black.copy(alpha = 0.5f), fontWeight = FontWeight.Medium, - fontSize = 12.sp, + maxFontSize = 12.sp, + minimumScaleFactor = 0.8f, ) Spacer(Modifier.weight(1f)) - Text( + AutoSizeText( text = groupedWord.word, color = CoveColor.midnightBlue, fontWeight = FontWeight.Medium, - fontSize = 14.sp, + maxFontSize = 14.sp, + minimumScaleFactor = 0.2f, ) Spacer(Modifier.weight(1f)) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index c034896ad..c25ef2574 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -9,7 +9,6 @@ import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,6 +34,8 @@ import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -66,10 +67,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -469,40 +470,52 @@ private fun WordInputGrid( val flatWords = enteredWords.flatten() - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Card( modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(10.dp), ) { - itemsIndexed( - items = flatWords, - key = { index, _ -> "word-input-$index" }, - ) { index, word -> - WordInputField( - number = index + 1, - word = word, - numberOfWords = numberOfWords, - allEnteredWords = enteredWords, - isFocused = focusedField == index, - onWordChanged = { newWord -> - val groupIndex = index / GROUPS_OF - val wordIndex = index % GROUPS_OF - val newWords = enteredWords.toMutableList() - val newGroup = newWords[groupIndex].toMutableList() - newGroup[wordIndex] = newWord - newWords[groupIndex] = newGroup - onWordsChanged(newWords) - }, - onFocusChanged = { hasFocus -> - if (hasFocus) onFocusChanged(index) - }, - onNext = { - if (index < wordCount - 1) { - onFocusChanged(index + 1) - } - }, - ) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + ) { + itemsIndexed( + items = flatWords, + key = { index, _ -> "word-input-$index" }, + ) { index, word -> + WordInputField( + number = index + 1, + word = word, + numberOfWords = numberOfWords, + allEnteredWords = enteredWords, + isFocused = focusedField == index, + onWordChanged = { newWord -> + val groupIndex = index / GROUPS_OF + val wordIndex = index % GROUPS_OF + val newWords = enteredWords.toMutableList() + val newGroup = newWords[groupIndex].toMutableList() + newGroup[wordIndex] = newWord + newWords[groupIndex] = newGroup + onWordsChanged(newWords) + }, + onFocusChanged = { hasFocus -> + if (hasFocus) onFocusChanged(index) + }, + onNext = { + if (index < wordCount - 1) { + onFocusChanged(index + 1) + } + }, + ) + } } } } @@ -531,22 +544,31 @@ private fun WordInputField( val isValid = word.isNotEmpty() && autocomplete.isBip39Word(word) val hasInput = word.isNotEmpty() - val borderColor = + // underline color based on state (matching iOS) + val underlineColor = when { - !hasInput -> Color.Transparent + !hasInput && !isFocused -> MaterialTheme.colorScheme.onSurfaceVariant + !hasInput && isFocused -> MaterialTheme.colorScheme.onSurface isValid -> CoveColor.SuccessGreen.copy(alpha = 0.6f) else -> CoveColor.ErrorRed.copy(alpha = 0.7f) } + // text color based on state (matching iOS) val textColor = when { - !hasInput -> CoveColor.coveLightGray.copy(alpha = 0.45f) + !hasInput -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) isValid -> CoveColor.SuccessGreen.copy(alpha = 0.8f) else -> CoveColor.ErrorRed } + // number color based on state + val numberColor = + when { + !hasInput -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current LaunchedEffect(isFocused) { if (isFocused) { @@ -567,72 +589,74 @@ private fun WordInputField( Column { Row( - modifier = - Modifier - .fillMaxWidth() - .height(40.dp) - .background( - color = Color.White.copy(alpha = 0.08f), - shape = RoundedCornerShape(8.dp), - ).border( - width = if (borderColor != Color.Transparent) 2.dp else 0.dp, - color = borderColor, - shape = RoundedCornerShape(8.dp), - ).padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, ) { + // number label with monospace font Text( text = "$number.", - color = CoveColor.coveLightGray.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.width(28.dp), + style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + color = numberColor, + modifier = Modifier.width(32.dp), ) - BasicTextField( - value = word, - onValueChange = { newValue -> - val trimmed = newValue.trim().lowercase() - onWordChanged(trimmed) + Spacer(Modifier.width(8.dp)) - // auto-advance when word is complete and valid - if (trimmed.isNotEmpty() && autocomplete.isBip39Word(trimmed)) { - suggestions = emptyList() - onNext() - } - }, - textStyle = - TextStyle( - color = textColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, - ), - singleLine = true, - cursorBrush = SolidColor(Color.White), - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { onNext() }, - ), - modifier = - Modifier - .weight(1f) - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - onFocusChanged(focusState.isFocused) - if (!focusState.isFocused) { - suggestions = emptyList() - } - }, - ) + // text field with underline + Box(modifier = Modifier.weight(1f)) { + BasicTextField( + value = word, + onValueChange = { newValue -> + val trimmed = newValue.trim().lowercase() + onWordChanged(trimmed) + + // auto-advance when word is complete and valid + if (trimmed.isNotEmpty() && autocomplete.isBip39Word(trimmed)) { + suggestions = emptyList() + onNext() + } + }, + textStyle = + TextStyle( + color = textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { onNext() }, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + onFocusChanged(focusState.isFocused) + if (!focusState.isFocused) { + suggestions = emptyList() + } + }, + ) + + // underline + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(underlineColor) + .align(Alignment.BottomStart), + ) + } } // suggestion dropdown @@ -642,18 +666,14 @@ private fun WordInputField( Modifier .fillMaxWidth() .background( - color = CoveColor.midnightBlue.copy(alpha = 0.95f), - shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), - ).border( - width = 1.dp, - color = CoveColor.coveLightGray.copy(alpha = 0.3f), + color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), ), ) { suggestions.forEach { suggestion -> Text( text = suggestion, - color = Color.White, + color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index fb7e31372..60a411133 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.WordValidator @@ -510,44 +511,11 @@ private fun OptionChip( text = text, color = textColor, fontWeight = FontWeight.Medium, - maxLines = 1, + maxFontSize = 14.sp, + minimumScaleFactor = 0.50f, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 6.dp), ) } } } - -@Composable -private fun AutoSizeText( - text: String, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontWeight: FontWeight? = null, - maxLines: Int = Int.MAX_VALUE, - textAlign: TextAlign? = null, - maxFontSize: Int = 14, - minFontSize: Int = 8, -) { - var fontSize by remember { mutableStateOf(maxFontSize) } - var readyToDraw by remember { mutableStateOf(false) } - - Text( - text = text, - color = color, - fontWeight = fontWeight, - fontSize = fontSize.sp, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, - textAlign = textAlign, - softWrap = false, - modifier = modifier, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow && fontSize > minFontSize) { - fontSize -= 1 - } else { - readyToDraw = true - } - }, - ) -} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt index 91c981e65..c92b0485c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/import_wallet/ImportWalletScreen.kt @@ -202,15 +202,21 @@ private fun EnterWordsWidget( val rightIndices = (6 until 12) fun numLabel(n: Int): String = String.format(Locale.US, "%2d.", n) - Box( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 24.dp), + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(10.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + ) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt index 004fa38d2..116877ee7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt @@ -50,11 +50,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +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.ui.theme.MaterialSpacing import org.bitcoinppl.cove.utils.toComposeColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection import org.bitcoinppl.cove.views.MaterialSettingsItem @@ -123,9 +125,10 @@ fun WalletSettingsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Text( - style = MaterialTheme.typography.bodyLarge, + AutoSizeText( text = metadata.name, + maxFontSize = 16.sp, + minimumScaleFactor = 0.75f, textAlign = TextAlign.Center, ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 2986f5e99..58bd6eeba 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -232,7 +232,7 @@ private fun WalletItem( text = wallet.name ?: "Wallet", color = Color.White, maxFontSize = 17.sp, - minimumScaleFactor = 0.90f, + minimumScaleFactor = 0.80f, modifier = Modifier.weight(1f), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index f61a57588..a9fa2ee57 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -79,7 +79,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.WalletManager @@ -286,15 +289,20 @@ fun TransactionDetailsScreen( var needsFrequentCheck = true var errors = 0 - while (true) { + while (isActive) { try { + ensureActive() + // refresh transaction details - transactionDetails = manager.rust.transactionDetails(txId = transactionDetails.txId()) + val details = manager.rust.transactionDetails(txId = transactionDetails.txId()) + if (!isActive) break + transactionDetails = details // get confirmations val blockNumber = transactionDetails.blockNumber() if (blockNumber != null) { val confirmations = manager.rust.numberOfConfirmations(blockHeight = blockNumber) + if (!isActive) break numberOfConfirmations = confirmations.toInt() // if fully confirmed, slow down polling @@ -312,6 +320,9 @@ fun TransactionDetailsScreen( } else { delay(NORMAL_POLL_INTERVAL_MS) } + } catch (e: CancellationException) { + // composable left composition, exit gracefully + break } catch (e: Exception) { android.util.Log.e("TransactionDetails", "error polling confirmations", e) errors++ diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt index 85d40a302..883bda469 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/RecoveryWords.kt @@ -129,9 +129,11 @@ fun RecoveryWordChip( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Text( + AutoSizeText( text = "$index.", color = CoveColor.midnightBlue, + maxFontSize = 14.sp, + minimumScaleFactor = 0.5f, ) Spacer(Modifier.width(8.dp)) AutoSizeText( @@ -141,7 +143,7 @@ fun RecoveryWordChip( modifier = Modifier.weight(1f), textAlign = TextAlign.Center, maxFontSize = 14.sp, - minimumScaleFactor = 0.7f, + minimumScaleFactor = 0.75f, ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 014899b03..b3d064558 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -1,8 +1,10 @@ package org.bitcoinppl.cove.wallet_transactions +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,6 +30,8 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -66,11 +70,15 @@ import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText +import org.bitcoinppl.cove.views.BitcoinShieldIcon import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.HotWalletRoute import org.bitcoinppl.cove_core.NewWalletRoute import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.Transaction +import org.bitcoinppl.cove_core.WalletSettingsRoute +import org.bitcoinppl.cove_core.WalletType import org.bitcoinppl.cove_core.types.TransactionDirection import org.bitcoinppl.cove_core.types.WalletId import java.text.NumberFormat @@ -109,7 +117,7 @@ private fun WalletTransactionsDarkPreview() { ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun WalletTransactionsScreen( onBack: () -> Unit, @@ -156,6 +164,10 @@ fun WalletTransactionsScreen( val listState = rememberLazyListState() val isScrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + // state for wallet name rename dropdown + var showRenameMenu by remember { mutableStateOf(false) } + val isColdWallet = manager?.walletMetadata?.walletType == WalletType.COLD + Scaffold( containerColor = CoveColor.midnightBlue, topBar = { @@ -168,14 +180,53 @@ fun WalletTransactionsScreen( navigationIconContentColor = Color.White, ), title = { - if (isScrolled) { - AutoSizeText( - text = actualWalletName, - maxFontSize = 16.sp, - minimumScaleFactor = 0.75f, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) + Box { + Row( + modifier = Modifier + .combinedClickable( + onClick = {}, + onLongClick = { showRenameMenu = true }, + ) + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isColdWallet) { + BitcoinShieldIcon( + size = 13.dp, + color = Color.White, + ) + Spacer(modifier = Modifier.size(8.dp)) + } + AutoSizeText( + text = actualWalletName, + maxFontSize = 16.sp, + minimumScaleFactor = 0.75f, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } + DropdownMenu( + expanded = showRenameMenu, + onDismissRequest = { showRenameMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.change_name)) }, + onClick = { + showRenameMenu = false + manager?.walletMetadata?.id?.let { id -> + app?.pushRoute( + Route.Settings( + SettingsRoute.Wallet( + id = id, + route = WalletSettingsRoute.CHANGE_NAME, + ), + ), + ) + } + }, + ) + } } }, navigationIcon = { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 854669d9f..a3daa4e21 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Wallet type Settings Name + Change Name Wallet color Show transaction labels Danger zone diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift index a97dab267..1d3908680 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletCreateScreen.swift @@ -230,7 +230,7 @@ struct WordCardView: View { .foregroundStyle(.midnightBlue) .multilineTextAlignment(.center) .frame(alignment: .leading) - .minimumScaleFactor(0.30) + .minimumScaleFactor(0.2) .lineLimit(sizeCategory >= .extraExtraLarge ? 5 : 1) .font(isMiniDeviceOrLargeText(sizeCategory) ? .caption2 : .footnote) From 4e17020e681299fb05e95703f8aa92e5fa9fef1e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 26 Nov 2025 20:55:48 -0600 Subject: [PATCH 17/97] Create a pin screen for Android. --- .../cove/send/CoinControlSetAmountScreen.kt | 17 +- .../org/bitcoinppl/cove/send/SendScreen.kt | 2 +- .../bitcoinppl/cove/settings/ChangePinView.kt | 63 ++ .../bitcoinppl/cove/settings/DecoyPinView.kt | 48 ++ .../bitcoinppl/cove/settings/NewPinView.kt | 48 ++ .../cove/settings/SettingsScreen.kt | 719 +++++++++++++++++- .../cove/settings/WipeDataPinView.kt | 48 ++ .../cove/views/BitcoinShieldIcon.kt | 164 ++-- .../WalletTransactionsScreen.kt | 12 +- 9 files changed, 995 insertions(+), 126 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/settings/ChangePinView.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/settings/DecoyPinView.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/settings/NewPinView.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/settings/WipeDataPinView.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt index 5140ea649..ce0976d34 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton @OptIn(ExperimentalMaterial3Api::class) @@ -70,7 +71,7 @@ fun CoinControlSetAmountScreen( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( colors = @@ -271,15 +272,13 @@ private fun CoinControlAmountWidget( verticalAlignment = Alignment.Bottom, ) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text( + BalanceAutoSizeText( text = amount, - style = - TextStyle( - color = MaterialTheme.colorScheme.onSurface, - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Right, - ), + color = MaterialTheme.colorScheme.onSurface, + baseFontSize = 48.sp, + minimumScaleFactor = 0.01f, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Right, modifier = Modifier.fillMaxWidth(), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index b4934a78b..22cd855fb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -96,7 +96,7 @@ fun SendScreen( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( colors = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/ChangePinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/ChangePinView.kt new file mode 100644 index 000000000..64a3ff42b --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/ChangePinView.kt @@ -0,0 +1,63 @@ +package org.bitcoinppl.cove.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.views.NumberPadPinView + +private sealed class ChangePinState { + data object Current : ChangePinState() + + data object New : ChangePinState() + + data class Confirm( + val pinToConfirm: String, + ) : ChangePinState() +} + +@Composable +fun ChangePinView( + isPinCorrect: (String) -> Boolean, + backAction: () -> Unit, + onComplete: (String) -> Unit, +) { + var pinState: ChangePinState by remember { mutableStateOf(ChangePinState.Current) } + + when (val state = pinState) { + is ChangePinState.Current -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = isPinCorrect, + showPin = false, + backAction = backAction, + onUnlock = { + pinState = ChangePinState.New + }, + ) + } + + is ChangePinState.New -> { + NumberPadPinView( + title = "Enter New PIN", + isPinCorrect = { true }, + showPin = true, + backAction = backAction, + onUnlock = { enteredPin -> + pinState = ChangePinState.Confirm(enteredPin) + }, + ) + } + + is ChangePinState.Confirm -> { + NumberPadPinView( + title = "Confirm New PIN", + isPinCorrect = { it == state.pinToConfirm }, + showPin = false, + backAction = backAction, + onUnlock = onComplete, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/DecoyPinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/DecoyPinView.kt new file mode 100644 index 000000000..85ca92670 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/DecoyPinView.kt @@ -0,0 +1,48 @@ +package org.bitcoinppl.cove.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.views.NumberPadPinView + +private sealed class DecoyPinState { + data object New : DecoyPinState() + + data class Confirm( + val pinToConfirm: String, + ) : DecoyPinState() +} + +@Composable +fun DecoyPinView( + onComplete: (String) -> Unit, + backAction: () -> Unit, +) { + var pinState: DecoyPinState by remember { mutableStateOf(DecoyPinState.New) } + + when (val state = pinState) { + is DecoyPinState.New -> { + NumberPadPinView( + title = "Enter Decoy PIN", + isPinCorrect = { true }, + showPin = false, + backAction = backAction, + onUnlock = { enteredPin -> + pinState = DecoyPinState.Confirm(enteredPin) + }, + ) + } + + is DecoyPinState.Confirm -> { + NumberPadPinView( + title = "Confirm Decoy PIN", + isPinCorrect = { it == state.pinToConfirm }, + showPin = false, + backAction = backAction, + onUnlock = onComplete, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NewPinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NewPinView.kt new file mode 100644 index 000000000..a8dc40989 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NewPinView.kt @@ -0,0 +1,48 @@ +package org.bitcoinppl.cove.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.views.NumberPadPinView + +private sealed class NewPinState { + data object New : NewPinState() + + data class Confirm( + val pinToConfirm: String, + ) : NewPinState() +} + +@Composable +fun NewPinView( + onComplete: (String) -> Unit, + backAction: () -> Unit, +) { + var pinState: NewPinState by remember { mutableStateOf(NewPinState.New) } + + when (val state = pinState) { + is NewPinState.New -> { + NumberPadPinView( + title = "Enter New PIN", + isPinCorrect = { true }, + showPin = true, + backAction = backAction, + onUnlock = { enteredPin -> + pinState = NewPinState.Confirm(enteredPin) + }, + ) + } + + is NewPinState.Confirm -> { + NumberPadPinView( + title = "Confirm New PIN", + isPinCorrect = { it == state.pinToConfirm }, + showPin = false, + backAction = backAction, + onUnlock = onComplete, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index 50885e991..7d7e4280e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -2,6 +2,8 @@ package org.bitcoinppl.cove.settings import androidx.activity.compose.BackHandler 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.WindowInsets @@ -41,21 +43,86 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.R 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.views.WalletIcon +import org.bitcoinppl.cove_core.AuthManagerAction +import org.bitcoinppl.cove_core.AuthManagerException +import org.bitcoinppl.cove_core.AuthType import org.bitcoinppl.cove_core.Database import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.WalletMetadata import org.bitcoinppl.cove_core.WalletSettingsRoute +import org.bitcoinppl.cove_core.types.WalletId + +// sheet states for full-screen PIN flows +private sealed class SecuritySheetState { + data object None : SecuritySheetState() + + data object NewPin : SecuritySheetState() + + data object RemovePin : SecuritySheetState() + + data object ChangePin : SecuritySheetState() + + data object EnableBiometric : SecuritySheetState() + + data object DisableBiometric : SecuritySheetState() + + data object EnableWipeDataPin : SecuritySheetState() + + data class RemoveWipeDataPin( + val nextState: SecuritySheetState? = null, + ) : SecuritySheetState() + + data object EnableDecoyPin : SecuritySheetState() + + data class RemoveDecoyPin( + val nextState: SecuritySheetState? = null, + ) : SecuritySheetState() + + data object RemoveAllTrickPins : SecuritySheetState() +} + +// alert states for validation dialogs +private sealed class SecurityAlertState { + data class UnverifiedWallets( + val walletId: WalletId, + ) : SecurityAlertState() + + data object ConfirmEnableWipeMePin : SecurityAlertState() + + data object ConfirmDecoyPin : SecurityAlertState() + + data object NoteNoFaceIdWhenTrickPins : SecurityAlertState() + + data object NoteNoFaceIdWhenWipeMePin : SecurityAlertState() + + data object NoteNoFaceIdWhenDecoyPin : SecurityAlertState() + + data object NotePinRequired : SecurityAlertState() + + data class NoteFaceIdDisabling( + val nextAlert: SecurityAlertState, + ) : SecurityAlertState() + + data class ExtraSetPinError( + val message: String, + ) : SecurityAlertState() +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -270,7 +337,8 @@ private fun WalletSettingsSection(app: org.bitcoinppl.cove.AppManager) { @Composable private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { val context = LocalContext.current - val auth = org.bitcoinppl.cove.Auth + val activity = context as? FragmentActivity + val auth = Auth val biometricManager = remember { BiometricManager.from(context) } val isBiometricAvailable = @@ -279,18 +347,218 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { BiometricManager.BIOMETRIC_SUCCESS } - var isBiometricEnabled by remember { - mutableStateOf( - auth.type == org.bitcoinppl.cove_core.AuthType.BOTH || - auth.type == org.bitcoinppl.cove_core.AuthType.BIOMETRIC, - ) + // sheet and alert state + 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) } + + // 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 + } + + // toggle handlers + fun onBiometricToggle(enable: Boolean) { + if (auth.isInDecoyMode()) { + decoyModeFaceIdEnabled = enable + return + } + + if (!enable) { + sheetState = SecuritySheetState.DisableBiometric + return + } + + // check trick PINs before enabling biometrics + when { + auth.isDecoyPinEnabled && auth.isWipeDataPinEnabled -> + alertState = SecurityAlertState.NoteNoFaceIdWhenTrickPins + + auth.isWipeDataPinEnabled -> + alertState = SecurityAlertState.NoteNoFaceIdWhenWipeMePin + + auth.isDecoyPinEnabled -> + alertState = SecurityAlertState.NoteNoFaceIdWhenDecoyPin + + else -> + sheetState = SecuritySheetState.EnableBiometric + } } - var isPinEnabled by remember { - mutableStateOf( - auth.type == org.bitcoinppl.cove_core.AuthType.BOTH || - auth.type == org.bitcoinppl.cove_core.AuthType.PIN, - ) + fun onPinToggle(enable: Boolean) { + if (auth.isInDecoyMode()) { + decoyModePinEnabled = enable + return + } + + sheetState = if (enable) SecuritySheetState.NewPin else SecuritySheetState.RemovePin + } + + fun onWipeDataPinToggle(enable: Boolean) { + if (!enable) { + if (auth.isInDecoyMode()) { + decoyModeWipeDataPinEnabled = false + return + } + sheetState = SecuritySheetState.RemoveWipeDataPin() + return + } + + // check unverified wallets + val unverified = app.rust.unverifiedWalletIds() + if (unverified.isNotEmpty()) { + alertState = SecurityAlertState.UnverifiedWallets(unverified.first()) + return + } + + // PIN is required + if (auth.type == AuthType.BIOMETRIC) { + alertState = SecurityAlertState.NotePinRequired + return + } + + // must disable biometric first + if (auth.type == AuthType.BOTH) { + alertState = SecurityAlertState.NoteFaceIdDisabling(SecurityAlertState.ConfirmEnableWipeMePin) + return + } + + alertState = SecurityAlertState.ConfirmEnableWipeMePin + } + + fun onDecoyPinToggle(enable: Boolean) { + if (!enable) { + if (auth.isInDecoyMode()) { + decoyModeDecoyPinEnabled = false + return + } + sheetState = SecuritySheetState.RemoveDecoyPin() + return + } + + if (auth.type == AuthType.BIOMETRIC) { + alertState = SecurityAlertState.NotePinRequired + return + } + + if (auth.type == AuthType.BOTH) { + alertState = SecurityAlertState.NoteFaceIdDisabling(SecurityAlertState.ConfirmDecoyPin) + return + } + + alertState = SecurityAlertState.ConfirmDecoyPin + } + + // setter functions + fun setPin(pin: String) { + if (auth.isInDecoyMode()) { + decoyModePinEnabled = true + sheetState = SecuritySheetState.None + return + } + auth.dispatch(AuthManagerAction.SetPin(pin)) + sheetState = SecuritySheetState.None + } + + fun setWipeDataPin(pin: String) { + sheetState = SecuritySheetState.None + if (auth.isInDecoyMode()) { + decoyModeWipeDataPinEnabled = true + return + } + + try { + auth.rust.setWipeDataPin(pin) + } catch (e: AuthManagerException) { + alertState = SecurityAlertState.ExtraSetPinError(e.message ?: "Unknown error") + } + } + + fun setDecoyPin(pin: String) { + sheetState = SecuritySheetState.None + if (auth.isInDecoyMode()) { + decoyModeDecoyPinEnabled = true + return + } + + try { + auth.rust.setDecoyPin(pin) + } catch (e: AuthManagerException) { + alertState = SecurityAlertState.ExtraSetPinError(e.message ?: "Unknown error") + } + } + + // biometric prompt for enabling biometric + val biometricPrompt = + remember(activity) { + if (activity == null) return@remember null + + BiometricPrompt( + activity, + ContextCompat.getMainExecutor(context), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + sheetState = SecuritySheetState.None + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + auth.dispatch(AuthManagerAction.EnableBiometric) + sheetState = SecuritySheetState.None + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + } + }, + ) + } + + val promptInfo = + remember { + BiometricPrompt.PromptInfo + .Builder() + .setTitle("Enable Biometric") + .setSubtitle("Authenticate to enable biometric unlock") + .setNegativeButtonText("Cancel") + .build() + } + + // trigger biometric prompt when entering EnableBiometric state + LaunchedEffect(sheetState) { + if (sheetState == SecuritySheetState.EnableBiometric) { + biometricPrompt?.authenticate(promptInfo) + } } SectionHeader("Security") @@ -305,10 +573,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { icon = Icons.Default.Fingerprint, isSwitch = true, switchCheckedState = isBiometricEnabled, - onCheckChanged = { enabled -> - // TODO: Implement biometric enable/disable logic - isBiometricEnabled = enabled - }, + onCheckChanged = { enabled -> onBiometricToggle(enabled) }, ) itemCount++ } @@ -320,10 +585,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { icon = Icons.Default.Lock, isSwitch = true, switchCheckedState = isPinEnabled, - onCheckChanged = { enabled -> - // TODO: Implement PIN enable/disable logic - isPinEnabled = enabled - }, + onCheckChanged = { enabled -> onPinToggle(enabled) }, ) itemCount++ @@ -334,9 +596,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { MaterialSettingsItem( title = "Change PIN", icon = Icons.Default.LockOpen, - onClick = { - // TODO: Navigate to change PIN screen - }, + onClick = { sheetState = SecuritySheetState.ChangePin }, ) itemCount++ @@ -346,10 +606,8 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { title = "Enable Wipe Data PIN", icon = Icons.Default.Warning, isSwitch = true, - switchCheckedState = auth.isWipeDataPinEnabled, - onCheckChanged = { enabled -> - // TODO: Implement wipe data PIN toggle - }, + switchCheckedState = isWipeDataPinEnabled, + onCheckChanged = { enabled -> onWipeDataPinToggle(enabled) }, ) itemCount++ @@ -359,12 +617,415 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { title = "Enable Decoy PIN", icon = Icons.Default.Masks, isSwitch = true, - switchCheckedState = auth.isDecoyPinEnabled, - onCheckChanged = { enabled -> - // TODO: Implement decoy PIN toggle + switchCheckedState = isDecoyPinEnabled, + onCheckChanged = { enabled -> onDecoyPinToggle(enabled) }, + ) + } + } + } + + // alert dialogs + alertState?.let { state -> + SecurityAlertDialog( + state = state, + onDismiss = { alertState = null }, + onConfirm = { nextState -> + alertState = null + when (nextState) { + is SecurityAlertState -> alertState = nextState + is SecuritySheetState -> sheetState = nextState + } + }, + auth = auth, + app = app, + ) + } + + // full-screen sheet dialogs + if (sheetState != SecuritySheetState.None && sheetState != SecuritySheetState.EnableBiometric) { + SecuritySheetDialog( + state = sheetState, + onDismiss = { sheetState = SecuritySheetState.None }, + onNextState = { nextState -> sheetState = nextState }, + onSetPin = ::setPin, + onSetWipeDataPin = ::setWipeDataPin, + onSetDecoyPin = ::setDecoyPin, + auth = auth, + onAlertState = { alertState = it }, + ) + } +} + +@Composable +private fun SecurityAlertDialog( + state: SecurityAlertState, + onDismiss: () -> Unit, + onConfirm: (Any?) -> 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.rust.selectWallet(state.walletId) + } catch (_: Exception) { + } + onDismiss() + }, + ) { + Text("Go To Wallet") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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 = { onConfirm(SecuritySheetState.EnableWipeDataPin) }) { + Text("Yes, Enable Wipe Data PIN") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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 = { onConfirm(SecuritySheetState.EnableDecoyPin) }) { + Text("Yes, Enable Decoy PIN") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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") + } + }, + ) + } + + is SecurityAlertState.NoteFaceIdDisabling -> { + 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) + onConfirm(state.nextAlert) + }, + ) { + Text("Disable Biometric") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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 = { onConfirm(SecuritySheetState.RemoveAllTrickPins) }) { + Text("Yes, Disable trick PINs") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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 = { + val nextSheet = + if (!auth.isDecoyPinEnabled) { + SecuritySheetState.EnableBiometric + } else { + null + } + onConfirm(SecuritySheetState.RemoveWipeDataPin(nextSheet)) + }, + ) { + Text("Disable Wipe Data PIN") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } + + is 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 = { + val nextSheet = + if (!auth.isWipeDataPinEnabled) { + SecuritySheetState.EnableBiometric + } else { + null + } + onConfirm(SecuritySheetState.RemoveDecoyPin(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, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + when (state) { + is SecuritySheetState.NewPin -> { + NewPinView( + onComplete = onSetPin, + backAction = onDismiss, + ) + } + + is SecuritySheetState.RemovePin -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> + if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) + }, + showPin = false, + backAction = onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + onDismiss() + return@NumberPadPinView + } + auth.dispatch(AuthManagerAction.DisablePin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + onDismiss() + }, + ) + } + + is SecuritySheetState.ChangePin -> { + ChangePinView( + isPinCorrect = { pin -> + if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) + }, + backAction = onDismiss, + onComplete = { pin -> + if (auth.isInDecoyMode()) { + onDismiss() + return@ChangePinView + } + + if (auth.checkWipeDataPin(pin)) { + onDismiss() + onAlertState( + SecurityAlertState.ExtraSetPinError( + "Can't update PIN because it's the same as your wipe data PIN", + ), + ) + return@ChangePinView + } + + onSetPin(pin) }, ) } + + is SecuritySheetState.DisableBiometric -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableBiometric) + onDismiss() + }, + ) + } + + is SecuritySheetState.RemoveWipeDataPin -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + onDismiss() + return@NumberPadPinView + } + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + state.nextState?.let { onNextState(it) } ?: onDismiss() + }, + ) + } + + is SecuritySheetState.RemoveDecoyPin -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + state.nextState?.let { onNextState(it) } ?: onDismiss() + }, + ) + } + + is SecuritySheetState.RemoveAllTrickPins -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + onNextState(SecuritySheetState.EnableBiometric) + }, + ) + } + + is SecuritySheetState.EnableWipeDataPin -> { + WipeDataPinView( + onComplete = onSetWipeDataPin, + backAction = onDismiss, + ) + } + + is SecuritySheetState.EnableDecoyPin -> { + DecoyPinView( + onComplete = onSetDecoyPin, + backAction = onDismiss, + ) + } + + else -> {} } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WipeDataPinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WipeDataPinView.kt new file mode 100644 index 000000000..7eddd2fa4 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WipeDataPinView.kt @@ -0,0 +1,48 @@ +package org.bitcoinppl.cove.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.views.NumberPadPinView + +private sealed class WipeDataPinState { + data object New : WipeDataPinState() + + data class Confirm( + val pinToConfirm: String, + ) : WipeDataPinState() +} + +@Composable +fun WipeDataPinView( + onComplete: (String) -> Unit, + backAction: () -> Unit, +) { + var pinState: WipeDataPinState by remember { mutableStateOf(WipeDataPinState.New) } + + when (val state = pinState) { + is WipeDataPinState.New -> { + NumberPadPinView( + title = "Enter Wipe Data PIN", + isPinCorrect = { true }, + showPin = false, + backAction = backAction, + onUnlock = { enteredPin -> + pinState = WipeDataPinState.Confirm(enteredPin) + }, + ) + } + + is WipeDataPinState.Confirm -> { + NumberPadPinView( + title = "Confirm Wipe Data PIN", + isPinCorrect = { it == state.pinToConfirm }, + showPin = false, + backAction = backAction, + onUnlock = onComplete, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt index b1ac910cd..9e2b1edba 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt @@ -28,94 +28,96 @@ fun BitcoinShieldIcon( } private fun DrawScope.drawShield(color: Color) { - val path = Path().apply { - // outer shield - moveTo(51.625f, 124.688f) - cubicTo(50.5f, 124.688f, 48.875f, 124.188f, 47.5f, 123.375f) - cubicTo(12.625f, 102.938f, 0.937f, 95.5f, 0.937f, 74f) - lineTo(0.937f, 26.5f) - cubicTo(0.937f, 19.812f, 3.375f, 17.562f, 9.125f, 15.312f) - cubicTo(18f, 11.813f, 37.813f, 4.25f, 46.688f, 1.376f) - cubicTo(48.312f, 0.876f, 49.938f, 0.438f, 51.625f, 0.438f) - cubicTo(53.313f, 0.438f, 54.938f, 0.814f, 56.625f, 1.376f) - cubicTo(65.5f, 4.438f, 85.25f, 11.75f, 94.125f, 15.313f) - cubicTo(99.875f, 17.625f, 102.312f, 19.813f, 102.312f, 26.5f) - lineTo(102.312f, 74f) - cubicTo(102.312f, 95.5f, 90.812f, 103.125f, 55.812f, 123.375f) - cubicTo(54.375f, 124.188f, 52.812f, 124.688f, 51.625f, 124.688f) - close() + val path = + Path().apply { + // outer shield + moveTo(51.625f, 124.688f) + cubicTo(50.5f, 124.688f, 48.875f, 124.188f, 47.5f, 123.375f) + cubicTo(12.625f, 102.938f, 0.937f, 95.5f, 0.937f, 74f) + lineTo(0.937f, 26.5f) + cubicTo(0.937f, 19.812f, 3.375f, 17.562f, 9.125f, 15.312f) + cubicTo(18f, 11.813f, 37.813f, 4.25f, 46.688f, 1.376f) + cubicTo(48.312f, 0.876f, 49.938f, 0.438f, 51.625f, 0.438f) + cubicTo(53.313f, 0.438f, 54.938f, 0.814f, 56.625f, 1.376f) + cubicTo(65.5f, 4.438f, 85.25f, 11.75f, 94.125f, 15.313f) + cubicTo(99.875f, 17.625f, 102.312f, 19.813f, 102.312f, 26.5f) + lineTo(102.312f, 74f) + cubicTo(102.312f, 95.5f, 90.812f, 103.125f, 55.812f, 123.375f) + cubicTo(54.375f, 124.188f, 52.812f, 124.688f, 51.625f, 124.688f) + close() - // inner shield cutout - moveTo(51.625f, 116.125f) - cubicTo(52.688f, 116.125f, 53.813f, 115.625f, 55.25f, 114.688f) - cubicTo(84.188f, 96.562f, 94.688f, 91.5f, 94.688f, 72.5f) - lineTo(94.688f, 27.937f) - cubicTo(94.688f, 24.812f, 94.125f, 23.625f, 91.75f, 22.75f) - cubicTo(83.312f, 19.687f, 62.937f, 12.125f, 54.625f, 8.875f) - cubicTo(53.437f, 8.437f, 52.375f, 8.125f, 51.625f, 8.125f) - cubicTo(50.875f, 8.125f, 49.812f, 8.375f, 48.625f, 8.875f) - cubicTo(40.312f, 12.125f, 19.875f, 19.438f, 11.5f, 22.75f) - cubicTo(9.187f, 23.688f, 8.562f, 24.813f, 8.562f, 27.938f) - lineTo(8.562f, 72.5f) - cubicTo(8.563f, 91.5f, 19f, 96.688f, 48f, 114.688f) - cubicTo(49.438f, 115.625f, 50.625f, 116.125f, 51.625f, 116.125f) - close() - } + // inner shield cutout + moveTo(51.625f, 116.125f) + cubicTo(52.688f, 116.125f, 53.813f, 115.625f, 55.25f, 114.688f) + cubicTo(84.188f, 96.562f, 94.688f, 91.5f, 94.688f, 72.5f) + lineTo(94.688f, 27.937f) + cubicTo(94.688f, 24.812f, 94.125f, 23.625f, 91.75f, 22.75f) + cubicTo(83.312f, 19.687f, 62.937f, 12.125f, 54.625f, 8.875f) + cubicTo(53.437f, 8.437f, 52.375f, 8.125f, 51.625f, 8.125f) + cubicTo(50.875f, 8.125f, 49.812f, 8.375f, 48.625f, 8.875f) + cubicTo(40.312f, 12.125f, 19.875f, 19.438f, 11.5f, 22.75f) + cubicTo(9.187f, 23.688f, 8.562f, 24.813f, 8.562f, 27.938f) + lineTo(8.562f, 72.5f) + cubicTo(8.563f, 91.5f, 19f, 96.688f, 48f, 114.688f) + cubicTo(49.438f, 115.625f, 50.625f, 116.125f, 51.625f, 116.125f) + close() + } drawPath(path, color) } private fun DrawScope.drawBitcoin(color: Color) { - val path = Path().apply { - // main bitcoin B shape - moveTo(40.22f, 85.453f) - cubicTo(37.818f, 85.453f, 36.47f, 83.813f, 36.47f, 81.703f) - lineTo(36.47f, 37.728f) - cubicTo(36.47f, 35.502f, 37.965f, 33.978f, 40.22f, 33.978f) - lineTo(43.62f, 33.978f) - lineTo(43.62f, 28.324f) - cubicTo(43.62f, 27.211f, 44.41f, 26.42f, 45.553f, 26.42f) - cubicTo(46.695f, 26.42f, 47.486f, 27.21f, 47.486f, 28.324f) - lineTo(47.486f, 33.978f) - lineTo(52.994f, 33.978f) - lineTo(52.994f, 28.324f) - cubicTo(52.994f, 27.211f, 53.814f, 26.42f, 54.957f, 26.42f) - cubicTo(56.041f, 26.42f, 56.832f, 27.21f, 56.832f, 28.324f) - lineTo(56.832f, 34.096f) - cubicTo(64.244f, 34.886f, 69.693f, 39.369f, 69.693f, 46.723f) - cubicTo(69.693f, 52.26f, 65.709f, 57.182f, 60.231f, 58.148f) - lineTo(60.231f, 58.471f) - cubicTo(67.496f, 59.321f, 72.359f, 64.271f, 72.359f, 71.186f) - cubicTo(72.359f, 80.268f, 65.621f, 84.721f, 56.832f, 85.366f) - lineTo(56.832f, 91.313f) - cubicTo(56.832f, 92.426f, 56.041f, 93.246f, 54.957f, 93.246f) - cubicTo(53.815f, 93.246f, 52.994f, 92.426f, 52.994f, 91.313f) - lineTo(52.994f, 85.453f) - lineTo(47.486f, 85.453f) - lineTo(47.486f, 91.313f) - cubicTo(47.486f, 92.426f, 46.696f, 93.246f, 45.553f, 93.246f) - cubicTo(44.41f, 93.246f, 43.619f, 92.426f, 43.619f, 91.313f) - lineTo(43.619f, 85.453f) - lineTo(40.221f, 85.453f) - close() + val path = + Path().apply { + // main bitcoin B shape + moveTo(40.22f, 85.453f) + cubicTo(37.818f, 85.453f, 36.47f, 83.813f, 36.47f, 81.703f) + lineTo(36.47f, 37.728f) + cubicTo(36.47f, 35.502f, 37.965f, 33.978f, 40.22f, 33.978f) + lineTo(43.62f, 33.978f) + lineTo(43.62f, 28.324f) + cubicTo(43.62f, 27.211f, 44.41f, 26.42f, 45.553f, 26.42f) + cubicTo(46.695f, 26.42f, 47.486f, 27.21f, 47.486f, 28.324f) + lineTo(47.486f, 33.978f) + lineTo(52.994f, 33.978f) + lineTo(52.994f, 28.324f) + cubicTo(52.994f, 27.211f, 53.814f, 26.42f, 54.957f, 26.42f) + cubicTo(56.041f, 26.42f, 56.832f, 27.21f, 56.832f, 28.324f) + lineTo(56.832f, 34.096f) + cubicTo(64.244f, 34.886f, 69.693f, 39.369f, 69.693f, 46.723f) + cubicTo(69.693f, 52.26f, 65.709f, 57.182f, 60.231f, 58.148f) + lineTo(60.231f, 58.471f) + cubicTo(67.496f, 59.321f, 72.359f, 64.271f, 72.359f, 71.186f) + cubicTo(72.359f, 80.268f, 65.621f, 84.721f, 56.832f, 85.366f) + lineTo(56.832f, 91.313f) + cubicTo(56.832f, 92.426f, 56.041f, 93.246f, 54.957f, 93.246f) + cubicTo(53.815f, 93.246f, 52.994f, 92.426f, 52.994f, 91.313f) + lineTo(52.994f, 85.453f) + lineTo(47.486f, 85.453f) + lineTo(47.486f, 91.313f) + cubicTo(47.486f, 92.426f, 46.696f, 93.246f, 45.553f, 93.246f) + cubicTo(44.41f, 93.246f, 43.619f, 92.426f, 43.619f, 91.313f) + lineTo(43.619f, 85.453f) + lineTo(40.221f, 85.453f) + close() - // upper B bump - moveTo(42.273f, 56.391f) - lineTo(51.5f, 56.391f) - cubicTo(58.121f, 56.391f, 63.834f, 53.988f, 63.834f, 47.367f) - cubicTo(63.834f, 41.537f, 59.117f, 39.047f, 53.141f, 39.047f) - lineTo(42.27f, 39.047f) - lineTo(42.27f, 56.39f) - close() + // upper B bump + moveTo(42.273f, 56.391f) + lineTo(51.5f, 56.391f) + cubicTo(58.121f, 56.391f, 63.834f, 53.988f, 63.834f, 47.367f) + cubicTo(63.834f, 41.537f, 59.117f, 39.047f, 53.141f, 39.047f) + lineTo(42.27f, 39.047f) + lineTo(42.27f, 56.39f) + close() - // lower B bump - moveTo(42.27f, 80.384f) - lineTo(53.607f, 80.384f) - cubicTo(60.667f, 80.384f, 66.439f, 77.864f, 66.439f, 70.892f) - cubicTo(66.439f, 63.714f, 60.169f, 61.429f, 52.933f, 61.429f) - lineTo(42.272f, 61.429f) - lineTo(42.272f, 80.384f) - close() - } + // lower B bump + moveTo(42.27f, 80.384f) + lineTo(53.607f, 80.384f) + cubicTo(60.667f, 80.384f, 66.439f, 77.864f, 66.439f, 70.892f) + cubicTo(66.439f, 63.714f, 60.169f, 61.429f, 52.933f, 61.429f) + lineTo(42.272f, 61.429f) + lineTo(42.272f, 80.384f) + close() + } drawPath(path, color) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index b3d064558..815669dc8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -182,12 +182,12 @@ fun WalletTransactionsScreen( title = { Box { Row( - modifier = Modifier - .combinedClickable( - onClick = {}, - onLongClick = { showRenameMenu = true }, - ) - .padding(vertical = 8.dp, horizontal = 16.dp), + modifier = + Modifier + .combinedClickable( + onClick = {}, + onLongClick = { showRenameMenu = true }, + ).padding(vertical = 8.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { From 1c216a1902dab0a9abbd25377013b6a36cd380fd Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 10:07:30 -0600 Subject: [PATCH 18/97] WIP --- .../java/org/bitcoinppl/cove/MainActivity.kt | 26 +- .../cove/settings/SettingsScreen.kt | 541 ++++++++--------- .../cove/views/BitcoinShieldIcon.kt | 2 +- .../org/bitcoinppl/cove/views/LockView.kt | 53 +- .../WalletTransactionsScreen.kt | 43 +- .../java/org/bitcoinppl/cove_core/cove.kt | 571 ++++++++++++++++++ rust/src/manager/auth_manager.rs | 183 ++++++ 7 files changed, 1080 insertions(+), 339 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index aba15e2b1..85d4b1dfd 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -5,13 +5,14 @@ import android.net.Uri import android.os.Bundle import android.provider.Settings import android.util.Log -import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -36,10 +37,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.NfcScanSheet import org.bitcoinppl.cove.nfc.TapCardNfcManager @@ -51,10 +54,21 @@ import org.bitcoinppl.cove_core.TapSignerRoute import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat import org.bitcoinppl.cove_core.types.ColorSchemeSelection -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableEdgeToEdge( + statusBarStyle = + SystemBarStyle.auto( + lightScrim = android.graphics.Color.TRANSPARENT, + darkScrim = android.graphics.Color.TRANSPARENT, + ), + navigationBarStyle = + SystemBarStyle.auto( + lightScrim = android.graphics.Color.TRANSPARENT, + darkScrim = android.graphics.Color.TRANSPARENT, + ), + ) // initialize NFC manager with activity context TapCardNfcManager.getInstance().initialize(this) @@ -115,9 +129,11 @@ class MainActivity : ComponentActivity() { val snackbarHostState = remember { SnackbarHostState() } Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0), snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { + ) { _ -> + Box(modifier = Modifier.fillMaxSize()) { LockView { SidebarContainer(app = app) { // NavDisplay handles transitions and back gestures diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index 7d7e4280e..b82a085de 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import org.bitcoinppl.cove.Auth @@ -63,66 +65,13 @@ import org.bitcoinppl.cove_core.AuthManagerException import org.bitcoinppl.cove_core.AuthType import org.bitcoinppl.cove_core.Database import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.SecurityAlertState +import org.bitcoinppl.cove_core.SecuritySettingsAction +import org.bitcoinppl.cove_core.SecuritySettingsResult +import org.bitcoinppl.cove_core.SecuritySheetState import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.WalletMetadata import org.bitcoinppl.cove_core.WalletSettingsRoute -import org.bitcoinppl.cove_core.types.WalletId - -// sheet states for full-screen PIN flows -private sealed class SecuritySheetState { - data object None : SecuritySheetState() - - data object NewPin : SecuritySheetState() - - data object RemovePin : SecuritySheetState() - - data object ChangePin : SecuritySheetState() - - data object EnableBiometric : SecuritySheetState() - - data object DisableBiometric : SecuritySheetState() - - data object EnableWipeDataPin : SecuritySheetState() - - data class RemoveWipeDataPin( - val nextState: SecuritySheetState? = null, - ) : SecuritySheetState() - - data object EnableDecoyPin : SecuritySheetState() - - data class RemoveDecoyPin( - val nextState: SecuritySheetState? = null, - ) : SecuritySheetState() - - data object RemoveAllTrickPins : SecuritySheetState() -} - -// alert states for validation dialogs -private sealed class SecurityAlertState { - data class UnverifiedWallets( - val walletId: WalletId, - ) : SecurityAlertState() - - data object ConfirmEnableWipeMePin : SecurityAlertState() - - data object ConfirmDecoyPin : SecurityAlertState() - - data object NoteNoFaceIdWhenTrickPins : SecurityAlertState() - - data object NoteNoFaceIdWhenWipeMePin : SecurityAlertState() - - data object NoteNoFaceIdWhenDecoyPin : SecurityAlertState() - - data object NotePinRequired : SecurityAlertState() - - data class NoteFaceIdDisabling( - val nextAlert: SecurityAlertState, - ) : SecurityAlertState() - - data class ExtraSetPinError( - val message: String, - ) : SecurityAlertState() -} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -347,8 +296,8 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { BiometricManager.BIOMETRIC_SUCCESS } - // sheet and alert state - var sheetState: SecuritySheetState by remember { mutableStateOf(SecuritySheetState.None) } + // 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) @@ -357,6 +306,9 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { 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) } + // computed toggle values val isBiometricEnabled = if (auth.isInDecoyMode()) { @@ -386,111 +338,62 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { auth.isDecoyPinEnabled } - // toggle handlers - fun onBiometricToggle(enable: Boolean) { - if (auth.isInDecoyMode()) { - decoyModeFaceIdEnabled = enable - return - } - - if (!enable) { - sheetState = SecuritySheetState.DisableBiometric - return + // 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 -> {} + } + } } + } - // check trick PINs before enabling biometrics - when { - auth.isDecoyPinEnabled && auth.isWipeDataPinEnabled -> - alertState = SecurityAlertState.NoteNoFaceIdWhenTrickPins - - auth.isWipeDataPinEnabled -> - alertState = SecurityAlertState.NoteNoFaceIdWhenWipeMePin - - auth.isDecoyPinEnabled -> - alertState = SecurityAlertState.NoteNoFaceIdWhenDecoyPin - - else -> - sheetState = SecuritySheetState.EnableBiometric - } + // toggle handlers using Rust validation + fun onBiometricToggle(enable: Boolean) { + val action = SecuritySettingsAction.ToggleBiometric(enable) + val result = auth.rust.validateSecurityAction(action, app.rust.unverifiedWalletIds()) + handleSecurityResult(result, action) } fun onPinToggle(enable: Boolean) { - if (auth.isInDecoyMode()) { - decoyModePinEnabled = enable - return - } - - sheetState = if (enable) SecuritySheetState.NewPin else SecuritySheetState.RemovePin + val action = SecuritySettingsAction.TogglePin(enable) + val result = auth.rust.validateSecurityAction(action, app.rust.unverifiedWalletIds()) + handleSecurityResult(result, action) } fun onWipeDataPinToggle(enable: Boolean) { - if (!enable) { - if (auth.isInDecoyMode()) { - decoyModeWipeDataPinEnabled = false - return - } - sheetState = SecuritySheetState.RemoveWipeDataPin() - return - } - - // check unverified wallets - val unverified = app.rust.unverifiedWalletIds() - if (unverified.isNotEmpty()) { - alertState = SecurityAlertState.UnverifiedWallets(unverified.first()) - return - } - - // PIN is required - if (auth.type == AuthType.BIOMETRIC) { - alertState = SecurityAlertState.NotePinRequired - return - } - - // must disable biometric first - if (auth.type == AuthType.BOTH) { - alertState = SecurityAlertState.NoteFaceIdDisabling(SecurityAlertState.ConfirmEnableWipeMePin) - return - } - - alertState = SecurityAlertState.ConfirmEnableWipeMePin + val action = SecuritySettingsAction.ToggleWipeDataPin(enable) + val result = auth.rust.validateSecurityAction(action, app.rust.unverifiedWalletIds()) + handleSecurityResult(result, action) } fun onDecoyPinToggle(enable: Boolean) { - if (!enable) { - if (auth.isInDecoyMode()) { - decoyModeDecoyPinEnabled = false - return - } - sheetState = SecuritySheetState.RemoveDecoyPin() - return - } - - if (auth.type == AuthType.BIOMETRIC) { - alertState = SecurityAlertState.NotePinRequired - return - } - - if (auth.type == AuthType.BOTH) { - alertState = SecurityAlertState.NoteFaceIdDisabling(SecurityAlertState.ConfirmDecoyPin) - return - } - - alertState = SecurityAlertState.ConfirmDecoyPin + val action = SecuritySettingsAction.ToggleDecoyPin(enable) + val result = auth.rust.validateSecurityAction(action, app.rust.unverifiedWalletIds()) + handleSecurityResult(result, action) } // setter functions fun setPin(pin: String) { if (auth.isInDecoyMode()) { decoyModePinEnabled = true - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE return } auth.dispatch(AuthManagerAction.SetPin(pin)) - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE } fun setWipeDataPin(pin: String) { - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE if (auth.isInDecoyMode()) { decoyModeWipeDataPinEnabled = true return @@ -504,7 +407,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { } fun setDecoyPin(pin: String) { - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE if (auth.isInDecoyMode()) { decoyModeDecoyPinEnabled = true return @@ -528,13 +431,13 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) auth.dispatch(AuthManagerAction.EnableBiometric) - sheetState = SecuritySheetState.None + sheetState = SecuritySheetState.NONE } override fun onAuthenticationFailed() { @@ -556,7 +459,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { // trigger biometric prompt when entering EnableBiometric state LaunchedEffect(sheetState) { - if (sheetState == SecuritySheetState.EnableBiometric) { + if (sheetState == SecuritySheetState.ENABLE_BIOMETRIC) { biometricPrompt?.authenticate(promptInfo) } } @@ -596,7 +499,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { MaterialSettingsItem( title = "Change PIN", icon = Icons.Default.LockOpen, - onClick = { sheetState = SecuritySheetState.ChangePin }, + onClick = { sheetState = SecuritySheetState.CHANGE_PIN }, ) itemCount++ @@ -629,12 +532,12 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { SecurityAlertDialog( state = state, onDismiss = { alertState = null }, - onConfirm = { nextState -> + onConfirmSheet = { nextSheet -> alertState = null - when (nextState) { - is SecurityAlertState -> alertState = nextState - is SecuritySheetState -> sheetState = nextState - } + sheetState = nextSheet + }, + onConfirmAlert = { nextAlert -> + alertState = nextAlert }, auth = auth, app = app, @@ -642,10 +545,10 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { } // full-screen sheet dialogs - if (sheetState != SecuritySheetState.None && sheetState != SecuritySheetState.EnableBiometric) { + if (sheetState != SecuritySheetState.NONE && sheetState != SecuritySheetState.ENABLE_BIOMETRIC) { SecuritySheetDialog( state = sheetState, - onDismiss = { sheetState = SecuritySheetState.None }, + onDismiss = { sheetState = SecuritySheetState.NONE }, onNextState = { nextState -> sheetState = nextState }, onSetPin = ::setPin, onSetWipeDataPin = ::setWipeDataPin, @@ -660,7 +563,8 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { private fun SecurityAlertDialog( state: SecurityAlertState, onDismiss: () -> Unit, - onConfirm: (Any?) -> Unit, + onConfirmSheet: (SecuritySheetState) -> Unit, + onConfirmAlert: (SecurityAlertState) -> Unit, auth: org.bitcoinppl.cove.AuthManager, app: org.bitcoinppl.cove.AppManager, ) { @@ -697,7 +601,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.ConfirmEnableWipeMePin -> { + SecurityAlertState.ConfirmEnableWipeMePin -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("Are you sure?") }, @@ -710,7 +614,7 @@ private fun SecurityAlertDialog( ) }, confirmButton = { - TextButton(onClick = { onConfirm(SecuritySheetState.EnableWipeDataPin) }) { + TextButton(onClick = { onConfirmSheet(SecuritySheetState.ENABLE_WIPE_DATA_PIN) }) { Text("Yes, Enable Wipe Data PIN") } }, @@ -722,7 +626,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.ConfirmDecoyPin -> { + SecurityAlertState.ConfirmDecoyPin -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("Are you sure?") }, @@ -735,7 +639,7 @@ private fun SecurityAlertDialog( ) }, confirmButton = { - TextButton(onClick = { onConfirm(SecuritySheetState.EnableDecoyPin) }) { + TextButton(onClick = { onConfirmSheet(SecuritySheetState.ENABLE_DECOY_PIN) }) { Text("Yes, Enable Decoy PIN") } }, @@ -747,7 +651,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.NotePinRequired -> { + SecurityAlertState.NotePinRequired -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("PIN is required") }, @@ -760,7 +664,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.NoteFaceIdDisabling -> { + SecurityAlertState.NoteFaceIdDisablingForWipeMePin -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("Disable Biometric Unlock?") }, @@ -774,7 +678,7 @@ private fun SecurityAlertDialog( TextButton( onClick = { auth.dispatch(AuthManagerAction.DisableBiometric) - onConfirm(state.nextAlert) + onConfirmAlert(SecurityAlertState.ConfirmEnableWipeMePin) }, ) { Text("Disable Biometric") @@ -788,7 +692,35 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.NoteNoFaceIdWhenTrickPins -> { + 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") }, @@ -799,7 +731,7 @@ private fun SecurityAlertDialog( ) }, confirmButton = { - TextButton(onClick = { onConfirm(SecuritySheetState.RemoveAllTrickPins) }) { + TextButton(onClick = { onConfirmSheet(SecuritySheetState.REMOVE_ALL_TRICK_PINS) }) { Text("Yes, Disable trick PINs") } }, @@ -811,7 +743,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> { + SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("Can't do that") }, @@ -819,13 +751,14 @@ private fun SecurityAlertDialog( confirmButton = { TextButton( onClick = { + // if no decoy PIN, we can enable biometric after removing wipe data PIN val nextSheet = if (!auth.isDecoyPinEnabled) { - SecuritySheetState.EnableBiometric + SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC } else { - null + SecuritySheetState.REMOVE_WIPE_DATA_PIN } - onConfirm(SecuritySheetState.RemoveWipeDataPin(nextSheet)) + onConfirmSheet(nextSheet) }, ) { Text("Disable Wipe Data PIN") @@ -839,7 +772,7 @@ private fun SecurityAlertDialog( ) } - is SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> { + SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> { AlertDialog( onDismissRequest = onDismiss, title = { Text("Can't do that") }, @@ -847,13 +780,14 @@ private fun SecurityAlertDialog( confirmButton = { TextButton( onClick = { + // if no wipe data PIN, we can enable biometric after removing decoy PIN val nextSheet = if (!auth.isWipeDataPinEnabled) { - SecuritySheetState.EnableBiometric + SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC } else { - null + SecuritySheetState.REMOVE_DECOY_PIN } - onConfirm(SecuritySheetState.RemoveDecoyPin(nextSheet)) + onConfirmSheet(nextSheet) }, ) { Text("Disable Decoy PIN") @@ -893,139 +827,168 @@ private fun SecuritySheetDialog( auth: org.bitcoinppl.cove.AuthManager, onAlertState: (SecurityAlertState) -> Unit, ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { - when (state) { - is SecuritySheetState.NewPin -> { - NewPinView( - onComplete = onSetPin, - backAction = onDismiss, - ) - } + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + when (state) { + SecuritySheetState.NEW_PIN -> { + NewPinView( + onComplete = onSetPin, + backAction = onDismiss, + ) + } - is SecuritySheetState.RemovePin -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> - if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) - }, - showPin = false, - backAction = onDismiss, - onUnlock = { - if (auth.isInDecoyMode()) { + SecuritySheetState.REMOVE_PIN -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> + if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) + }, + showPin = false, + backAction = onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + onDismiss() + return@NumberPadPinView + } + auth.dispatch(AuthManagerAction.DisablePin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) onDismiss() - return@NumberPadPinView - } - auth.dispatch(AuthManagerAction.DisablePin) - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onDismiss() - }, - ) - } + }, + ) + } - is SecuritySheetState.ChangePin -> { - ChangePinView( - isPinCorrect = { pin -> - if (auth.isInDecoyMode()) auth.checkDecoyPin(pin) else auth.checkPin(pin) - }, - backAction = onDismiss, - onComplete = { pin -> - if (auth.isInDecoyMode()) { - onDismiss() - return@ChangePinView - } + 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.rust.validateNewPin(pin) + if (error != null) { + onDismiss() + onAlertState(SecurityAlertState.ExtraSetPinError(error)) + return@ChangePinView + } - if (auth.checkWipeDataPin(pin)) { + onSetPin(pin) + }, + ) + } + + SecuritySheetState.DISABLE_BIOMETRIC -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableBiometric) onDismiss() - onAlertState( - SecurityAlertState.ExtraSetPinError( - "Can't update PIN because it's the same as your wipe data PIN", - ), - ) - return@ChangePinView - } + }, + ) + } - onSetPin(pin) - }, - ) - } + SecuritySheetState.REMOVE_WIPE_DATA_PIN -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + if (auth.isInDecoyMode()) { + onDismiss() + return@NumberPadPinView + } + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + onDismiss() + }, + ) + } - is SecuritySheetState.DisableBiometric -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - showPin = false, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableBiometric) - onDismiss() - }, - ) - } + SecuritySheetState.REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) + } - is SecuritySheetState.RemoveWipeDataPin -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - showPin = false, - backAction = onDismiss, - onUnlock = { - if (auth.isInDecoyMode()) { + SecuritySheetState.REMOVE_DECOY_PIN -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) onDismiss() - return@NumberPadPinView - } - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - state.nextState?.let { onNextState(it) } ?: onDismiss() - }, - ) - } + }, + ) + } - is SecuritySheetState.RemoveDecoyPin -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - showPin = false, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableDecoyPin) - state.nextState?.let { onNextState(it) } ?: onDismiss() - }, - ) - } + SecuritySheetState.REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) + } - is SecuritySheetState.RemoveAllTrickPins -> { - NumberPadPinView( - title = "Enter Current PIN", - isPinCorrect = { pin -> auth.checkPin(pin) }, - showPin = false, - backAction = onDismiss, - onUnlock = { - auth.dispatch(AuthManagerAction.DisableDecoyPin) - auth.dispatch(AuthManagerAction.DisableWipeDataPin) - onNextState(SecuritySheetState.EnableBiometric) - }, - ) - } + SecuritySheetState.REMOVE_ALL_TRICK_PINS -> { + NumberPadPinView( + title = "Enter Current PIN", + isPinCorrect = { pin -> auth.checkPin(pin) }, + showPin = false, + backAction = onDismiss, + onUnlock = { + auth.dispatch(AuthManagerAction.DisableDecoyPin) + auth.dispatch(AuthManagerAction.DisableWipeDataPin) + onNextState(SecuritySheetState.ENABLE_BIOMETRIC) + }, + ) + } - is SecuritySheetState.EnableWipeDataPin -> { - WipeDataPinView( - onComplete = onSetWipeDataPin, - backAction = onDismiss, - ) - } + SecuritySheetState.ENABLE_WIPE_DATA_PIN -> { + WipeDataPinView( + onComplete = onSetWipeDataPin, + backAction = onDismiss, + ) + } - is SecuritySheetState.EnableDecoyPin -> { - DecoyPinView( - onComplete = onSetDecoyPin, - backAction = onDismiss, - ) - } + SecuritySheetState.ENABLE_DECOY_PIN -> { + DecoyPinView( + onComplete = onSetDecoyPin, + backAction = onDismiss, + ) + } - else -> {} + else -> {} + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt index 9e2b1edba..8f0a72a65 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt @@ -19,7 +19,7 @@ fun BitcoinShieldIcon( color: Color = Color.White, ) { Canvas(modifier = modifier.size(size)) { - val scaleFactor = size.toPx() / 103f + val scaleFactor = size.toPx() / 125f scale(scaleFactor, scaleFactor) { drawShield(color) drawBitcoin(color) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index ccdd8564e..774a90ebe 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import org.bitcoinppl.cove.App import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.UnlockMode import org.bitcoinppl.cove_core.AuthType @@ -58,7 +57,6 @@ fun LockView( content: @Composable () -> Unit, ) { val auth = Auth - val app = App var screen by remember { mutableStateOf(Screen.BIOMETRIC) } val context = LocalContext.current val activity = context as? FragmentActivity @@ -69,9 +67,7 @@ fun LockView( biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS } - // biometric prompt - var showBiometric by remember { mutableStateOf(false) } - + // keep promptInfo cached (it's just config, doesn't go stale) val promptInfo = remember { PromptInfo @@ -82,24 +78,26 @@ fun LockView( .build() } - val biometricPrompt = - remember(activity) { - if (activity == null) return@remember null + // trigger function creates FRESH BiometricPrompt each time (like iOS creates fresh LAContext) + fun triggerBiometric() { + val act = activity ?: return + if (auth.isUsingBiometrics) return + + auth.isUsingBiometrics = true + val biometricPrompt = BiometricPrompt( - activity, + act, ContextCompat.getMainExecutor(context), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) auth.isUsingBiometrics = false - showBiometric = false } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) auth.isUsingBiometrics = false - showBiometric = false // if in decoy mode, switch back to main mode (biometric = trusted user = main mode) if (auth.isInDecoyMode()) { @@ -111,15 +109,19 @@ fun LockView( override fun onAuthenticationFailed() { super.onAuthenticationFailed() + // user can retry, don't clear flag yet } }, ) - } - // auto-trigger biometric on lock if auth type is biometric or both + biometricPrompt.authenticate(promptInfo) + } + + // set screen to biometric when locked with biometric auth + // (don't trigger biometric here - BiometricView's LaunchedEffect does that) LaunchedEffect(auth.isLocked, auth.type) { if (auth.isLocked && (auth.type == AuthType.BIOMETRIC || auth.type == AuthType.BOTH)) { - if (isBiometricAvailable && !showBiometric) { + if (isBiometricAvailable) { screen = Screen.BIOMETRIC } } @@ -151,17 +153,8 @@ fun LockView( screen == Screen.BIOMETRIC && (auth.type == AuthType.BIOMETRIC || auth.type == AuthType.BOTH) && isBiometricAvailable -> { BiometricView( showBoth = auth.type == AuthType.BOTH, - onBiometricTap = { - // guard against re-entry - if (!auth.isUsingBiometrics) { - auth.isUsingBiometrics = true - showBiometric = true - biometricPrompt?.authenticate(promptInfo) - } - }, - onEnterPinTap = { - screen = Screen.PIN - }, + onBiometricTap = { triggerBiometric() }, + onEnterPinTap = { screen = Screen.PIN }, ) } // show PIN screen @@ -226,6 +219,11 @@ private fun BiometricView( onBiometricTap: () -> Unit, onEnterPinTap: () -> Unit, ) { + // auto-trigger biometric when view appears (like iOS .onAppear) + LaunchedEffect(Unit) { + onBiometricTap() + } + Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -286,9 +284,4 @@ private fun BiometricView( } } } - - // auto-trigger biometric on appear - LaunchedEffect(Unit) { - onBiometricTap() - } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 815669dc8..c6d77c82d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer 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.lazy.LazyColumn @@ -72,11 +73,13 @@ import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.BitcoinShieldIcon import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.FiatOrBtc import org.bitcoinppl.cove_core.HotWalletRoute import org.bitcoinppl.cove_core.NewWalletRoute import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.Transaction +import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType import org.bitcoinppl.cove_core.types.TransactionDirection @@ -193,6 +196,7 @@ fun WalletTransactionsScreen( ) { if (isColdWallet) { BitcoinShieldIcon( + modifier = Modifier.offset(y = (-5).dp), size = 13.dp, color = Color.White, ) @@ -290,10 +294,18 @@ fun WalletTransactionsScreen( .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { + val fiatOrBtc = manager?.walletMetadata?.fiatOrBtc ?: FiatOrBtc.BTC + val (primaryAmount, secondaryAmount) = + when (fiatOrBtc) { + FiatOrBtc.FIAT -> actualUsdAmount to actualSatsAmount + FiatOrBtc.BTC -> actualSatsAmount to actualUsdAmount + } + BalanceWidget( - usdAmount = actualUsdAmount, - satsAmount = actualSatsAmount, hidden = initialBalanceHidden, + primaryAmount = primaryAmount, + secondaryAmount = secondaryAmount, + onToggle = { manager?.dispatch(WalletManagerAction.ToggleFiatBtcPrimarySecondary) }, ) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -581,21 +593,25 @@ private fun TransactionWidget( @Composable private fun BalanceWidget( hidden: Boolean, - usdAmount: String, - satsAmount: String, + primaryAmount: String, + secondaryAmount: String, + onToggle: () -> Unit, ) { var isHidden by remember { mutableStateOf(hidden) } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column( + modifier = Modifier.clickable { onToggle() }, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { Text( - text = if (isHidden) "$———" else usdAmount, + text = if (isHidden) "••••••" else secondaryAmount, color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp, ) Row(verticalAlignment = Alignment.CenterVertically) { BalanceAutoSizeText( - text = if (isHidden) "•••••• SATS" else satsAmount, + text = if (isHidden) "••••••" else primaryAmount, modifier = Modifier.padding(end = 12.dp), color = Color.White, baseFontSize = 34.sp, @@ -603,13 +619,12 @@ private fun BalanceWidget( fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { isHidden = !isHidden }) { - Icon( - imageVector = if (isHidden) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = if (isHidden) "Show" else "Hide", - tint = Color.White.copy(alpha = 0.7f), - ) - } + Icon( + imageVector = if (isHidden) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (isHidden) "Show" else "Hide", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.clickable { isHidden = !isHidden }, + ) } } } 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 6dcb3cc33..4747ec1a7 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 @@ -1303,8 +1303,12 @@ external fun uniffi_cove_checksum_method_rustauthmanager_switch_to_decoy_mode( ): Short external fun uniffi_cove_checksum_method_rustauthmanager_switch_to_main_mode( ): Short +external fun uniffi_cove_checksum_method_rustauthmanager_validate_new_pin( +): Short external fun uniffi_cove_checksum_method_rustauthmanager_validate_pin_settings( ): Short +external fun uniffi_cove_checksum_method_rustauthmanager_validate_security_action( +): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation( ): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_dispatch( @@ -2174,8 +2178,12 @@ external fun uniffi_cove_fn_method_rustauthmanager_switch_to_decoy_mode(`ptr`: L ): Unit external fun uniffi_cove_fn_method_rustauthmanager_switch_to_main_mode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit +external fun uniffi_cove_fn_method_rustauthmanager_validate_new_pin(`ptr`: Long,`newPin`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustauthmanager_validate_pin_settings(`ptr`: Long,`pin`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit +external fun uniffi_cove_fn_method_rustauthmanager_validate_security_action(`ptr`: Long,`action`: RustBuffer.ByValue,`unverifiedWalletIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_clone_rustcoincontrolmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_rustcoincontrolmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3571,9 +3579,15 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustauthmanager_switch_to_main_mode() != 36755.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustauthmanager_validate_new_pin() != 2677.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustauthmanager_validate_pin_settings() != 50929.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustauthmanager_validate_security_action() != 4302.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation() != 24764.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -16192,11 +16206,21 @@ public interface RustAuthManagerInterface { */ fun `switchToMainMode`() + /** + * Validate a new PIN doesn't conflict with existing PINs + */ + fun `validateNewPin`(`newPin`: kotlin.String): kotlin.String? + /** * Validate if we have the correct settings to be able to set a decoy or wipe data pin */ fun `validatePinSettings`(`pin`: kotlin.String) + /** + * Validate a security settings action and return what UI to show + */ + fun `validateSecurityAction`(`action`: SecuritySettingsAction, `unverifiedWalletIds`: List): SecuritySettingsResult + companion object } @@ -16570,6 +16594,22 @@ open class RustAuthManager: Disposable, AutoCloseable, RustAuthManagerInterface + /** + * Validate a new PIN doesn't conflict with existing PINs + */override fun `validateNewPin`(`newPin`: kotlin.String): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustauthmanager_validate_new_pin( + it, + FfiConverterString.lower(`newPin`),_status) +} + } + ) + } + + + /** * Validate if we have the correct settings to be able to set a decoy or wipe data pin */ @@ -16586,6 +16626,22 @@ open class RustAuthManager: Disposable, AutoCloseable, RustAuthManagerInterface + /** + * Validate a security settings action and return what UI to show + */override fun `validateSecurityAction`(`action`: SecuritySettingsAction, `unverifiedWalletIds`: List): SecuritySettingsResult { + return FfiConverterTypeSecuritySettingsResult.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustauthmanager_validate_security_action( + it, + FfiConverterTypeSecuritySettingsAction.lower(`action`),FfiConverterSequenceTypeWalletId.lower(`unverifiedWalletIds`),_status) +} + } + ) + } + + + @@ -32919,6 +32975,521 @@ public object FfiConverterTypeScannerResponse : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): SecurityAlertState { + return when(buf.getInt()) { + 1 -> SecurityAlertState.UnverifiedWallets( + FfiConverterTypeWalletId.read(buf), + ) + 2 -> SecurityAlertState.ConfirmEnableWipeMePin + 3 -> SecurityAlertState.ConfirmDecoyPin + 4 -> SecurityAlertState.NoteNoFaceIdWhenTrickPins + 5 -> SecurityAlertState.NoteNoFaceIdWhenWipeMePin + 6 -> SecurityAlertState.NoteNoFaceIdWhenDecoyPin + 7 -> SecurityAlertState.NotePinRequired + 8 -> SecurityAlertState.NoteFaceIdDisablingForWipeMePin + 9 -> SecurityAlertState.NoteFaceIdDisablingForDecoyPin + 10 -> SecurityAlertState.ExtraSetPinError( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: SecurityAlertState) = when(value) { + is SecurityAlertState.UnverifiedWallets -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeWalletId.allocationSize(value.`walletId`) + ) + } + is SecurityAlertState.ConfirmEnableWipeMePin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.ConfirmDecoyPin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NoteNoFaceIdWhenTrickPins -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NotePinRequired -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NoteFaceIdDisablingForWipeMePin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.NoteFaceIdDisablingForDecoyPin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is SecurityAlertState.ExtraSetPinError -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`message`) + ) + } + } + + override fun write(value: SecurityAlertState, buf: ByteBuffer) { + when(value) { + is SecurityAlertState.UnverifiedWallets -> { + buf.putInt(1) + FfiConverterTypeWalletId.write(value.`walletId`, buf) + Unit + } + is SecurityAlertState.ConfirmEnableWipeMePin -> { + buf.putInt(2) + Unit + } + is SecurityAlertState.ConfirmDecoyPin -> { + buf.putInt(3) + Unit + } + is SecurityAlertState.NoteNoFaceIdWhenTrickPins -> { + buf.putInt(4) + Unit + } + is SecurityAlertState.NoteNoFaceIdWhenWipeMePin -> { + buf.putInt(5) + Unit + } + is SecurityAlertState.NoteNoFaceIdWhenDecoyPin -> { + buf.putInt(6) + Unit + } + is SecurityAlertState.NotePinRequired -> { + buf.putInt(7) + Unit + } + is SecurityAlertState.NoteFaceIdDisablingForWipeMePin -> { + buf.putInt(8) + Unit + } + is SecurityAlertState.NoteFaceIdDisablingForDecoyPin -> { + buf.putInt(9) + Unit + } + is SecurityAlertState.ExtraSetPinError -> { + buf.putInt(10) + FfiConverterString.write(value.`message`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + +/** + * Action the user wants to take on security settings + */ +sealed class SecuritySettingsAction { + + data class ToggleBiometric( + val `enable`: kotlin.Boolean) : SecuritySettingsAction() + + { + + + companion object + } + + data class TogglePin( + val `enable`: kotlin.Boolean) : SecuritySettingsAction() + + { + + + companion object + } + + data class ToggleWipeDataPin( + val `enable`: kotlin.Boolean) : SecuritySettingsAction() + + { + + + companion object + } + + data class ToggleDecoyPin( + val `enable`: kotlin.Boolean) : SecuritySettingsAction() + + { + + + companion object + } + + object ChangePin : SecuritySettingsAction() + + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeSecuritySettingsAction : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): SecuritySettingsAction { + return when(buf.getInt()) { + 1 -> SecuritySettingsAction.ToggleBiometric( + FfiConverterBoolean.read(buf), + ) + 2 -> SecuritySettingsAction.TogglePin( + FfiConverterBoolean.read(buf), + ) + 3 -> SecuritySettingsAction.ToggleWipeDataPin( + FfiConverterBoolean.read(buf), + ) + 4 -> SecuritySettingsAction.ToggleDecoyPin( + FfiConverterBoolean.read(buf), + ) + 5 -> SecuritySettingsAction.ChangePin + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: SecuritySettingsAction) = when(value) { + is SecuritySettingsAction.ToggleBiometric -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`enable`) + ) + } + is SecuritySettingsAction.TogglePin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`enable`) + ) + } + is SecuritySettingsAction.ToggleWipeDataPin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`enable`) + ) + } + is SecuritySettingsAction.ToggleDecoyPin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`enable`) + ) + } + is SecuritySettingsAction.ChangePin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + } + + override fun write(value: SecuritySettingsAction, buf: ByteBuffer) { + when(value) { + is SecuritySettingsAction.ToggleBiometric -> { + buf.putInt(1) + FfiConverterBoolean.write(value.`enable`, buf) + Unit + } + is SecuritySettingsAction.TogglePin -> { + buf.putInt(2) + FfiConverterBoolean.write(value.`enable`, buf) + Unit + } + is SecuritySettingsAction.ToggleWipeDataPin -> { + buf.putInt(3) + FfiConverterBoolean.write(value.`enable`, buf) + Unit + } + is SecuritySettingsAction.ToggleDecoyPin -> { + buf.putInt(4) + FfiConverterBoolean.write(value.`enable`, buf) + Unit + } + is SecuritySettingsAction.ChangePin -> { + buf.putInt(5) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + +/** + * Result of validating a security settings action + */ +sealed class SecuritySettingsResult { + + /** + * Proceed to show a sheet for PIN entry + */ + data class ProceedToSheet( + val v1: org.bitcoinppl.cove_core.SecuritySheetState) : SecuritySettingsResult() + + { + + + companion object + } + + /** + * Show an alert dialog + */ + data class ShowAlert( + val v1: org.bitcoinppl.cove_core.SecurityAlertState) : SecuritySettingsResult() + + { + + + companion object + } + + /** + * Decoy mode - just update local UI, don't persist + */ + object DecoyModeLocalUpdate : SecuritySettingsResult() + + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeSecuritySettingsResult : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): SecuritySettingsResult { + return when(buf.getInt()) { + 1 -> SecuritySettingsResult.ProceedToSheet( + FfiConverterTypeSecuritySheetState.read(buf), + ) + 2 -> SecuritySettingsResult.ShowAlert( + FfiConverterTypeSecurityAlertState.read(buf), + ) + 3 -> SecuritySettingsResult.DecoyModeLocalUpdate + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: SecuritySettingsResult) = when(value) { + is SecuritySettingsResult.ProceedToSheet -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeSecuritySheetState.allocationSize(value.v1) + ) + } + is SecuritySettingsResult.ShowAlert -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeSecurityAlertState.allocationSize(value.v1) + ) + } + is SecuritySettingsResult.DecoyModeLocalUpdate -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + } + + override fun write(value: SecuritySettingsResult, buf: ByteBuffer) { + when(value) { + is SecuritySettingsResult.ProceedToSheet -> { + buf.putInt(1) + FfiConverterTypeSecuritySheetState.write(value.v1, buf) + Unit + } + is SecuritySettingsResult.ShowAlert -> { + buf.putInt(2) + FfiConverterTypeSecurityAlertState.write(value.v1, buf) + Unit + } + is SecuritySettingsResult.DecoyModeLocalUpdate -> { + buf.putInt(3) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + +/** + * What sheet to show for PIN entry flows + */ + +enum class SecuritySheetState { + + NONE, + NEW_PIN, + REMOVE_PIN, + CHANGE_PIN, + ENABLE_BIOMETRIC, + DISABLE_BIOMETRIC, + ENABLE_WIPE_DATA_PIN, + REMOVE_WIPE_DATA_PIN, + /** + * Remove wipe data PIN, then enable biometric + */ + REMOVE_WIPE_DATA_PIN_THEN_ENABLE_BIOMETRIC, + ENABLE_DECOY_PIN, + REMOVE_DECOY_PIN, + /** + * Remove decoy PIN, then enable biometric + */ + REMOVE_DECOY_PIN_THEN_ENABLE_BIOMETRIC, + REMOVE_ALL_TRICK_PINS; + + + + + companion object +} + + +/** + * @suppress + */ +public object FfiConverterTypeSecuritySheetState: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + SecuritySheetState.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: SecuritySheetState) = 4UL + + override fun write(value: SecuritySheetState, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + sealed class SeedQrException: kotlin.Exception() { diff --git a/rust/src/manager/auth_manager.rs b/rust/src/manager/auth_manager.rs index 617a2a2e6..7a558742a 100644 --- a/rust/src/manager/auth_manager.rs +++ b/rust/src/manager/auth_manager.rs @@ -6,6 +6,8 @@ use parking_lot::RwLock; use tap::TapFallible as _; use tracing::{debug, error}; +use cove_types::WalletId; + use crate::{ app::reconcile::{AppStateReconcileMessage, Updater}, auth::{AuthPin, AuthType}, @@ -36,6 +38,70 @@ pub enum AuthManagerAction { DisableDecoyPin, } +// MARK: Security Settings Validation + +/// Action the user wants to take on security settings +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum SecuritySettingsAction { + ToggleBiometric { enable: bool }, + TogglePin { enable: bool }, + ToggleWipeDataPin { enable: bool }, + ToggleDecoyPin { enable: bool }, + ChangePin, +} + +/// What sheet to show for PIN entry flows +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum SecuritySheetState { + None, + NewPin, + RemovePin, + ChangePin, + EnableBiometric, + DisableBiometric, + EnableWipeDataPin, + RemoveWipeDataPin, + /// Remove wipe data PIN, then enable biometric + RemoveWipeDataPinThenEnableBiometric, + EnableDecoyPin, + RemoveDecoyPin, + /// Remove decoy PIN, then enable biometric + RemoveDecoyPinThenEnableBiometric, + RemoveAllTrickPins, +} + +/// What alert to show for validation messages +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum SecurityAlertState { + UnverifiedWallets { + wallet_id: WalletId, + }, + ConfirmEnableWipeMePin, + ConfirmDecoyPin, + NoteNoFaceIdWhenTrickPins, + NoteNoFaceIdWhenWipeMePin, + NoteNoFaceIdWhenDecoyPin, + NotePinRequired, + /// Disabling biometric, then show confirm for wipe me PIN + NoteFaceIdDisablingForWipeMePin, + /// Disabling biometric, then show confirm for decoy PIN + NoteFaceIdDisablingForDecoyPin, + ExtraSetPinError { + message: String, + }, +} + +/// Result of validating a security settings action +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum SecuritySettingsResult { + /// Proceed to show a sheet for PIN entry + ProceedToSheet(SecuritySheetState), + /// Show an alert dialog + ShowAlert(SecurityAlertState), + /// Decoy mode - just update local UI, don't persist + DecoyModeLocalUpdate, +} + #[derive(Clone, Debug, uniffi::Object)] pub struct RustAuthManager { #[allow(dead_code)] @@ -345,6 +411,123 @@ impl RustAuthManager { Action::DisableDecoyPin => self.delete_decoy_pin(), } } + + // MARK: Security Settings Validation + + /// Validate a security settings action and return what UI to show + pub fn validate_security_action( + &self, + action: SecuritySettingsAction, + unverified_wallet_ids: Vec, + ) -> SecuritySettingsResult { + use SecuritySettingsAction::*; + use SecuritySettingsResult::*; + + match action { + ToggleBiometric { enable } => { + if self.is_in_decoy_mode() { + return DecoyModeLocalUpdate; + } + + if !enable { + return ProceedToSheet(SecuritySheetState::DisableBiometric); + } + + // check trick PINs before enabling biometrics + if self.is_decoy_pin_enabled() && self.is_wipe_data_pin_enabled() { + return ShowAlert(SecurityAlertState::NoteNoFaceIdWhenTrickPins); + } + + if self.is_wipe_data_pin_enabled() { + return ShowAlert(SecurityAlertState::NoteNoFaceIdWhenWipeMePin); + } + + if self.is_decoy_pin_enabled() { + return ShowAlert(SecurityAlertState::NoteNoFaceIdWhenDecoyPin); + } + + ProceedToSheet(SecuritySheetState::EnableBiometric) + } + + TogglePin { enable } => { + if self.is_in_decoy_mode() { + return DecoyModeLocalUpdate; + } + + if enable { + ProceedToSheet(SecuritySheetState::NewPin) + } else { + ProceedToSheet(SecuritySheetState::RemovePin) + } + } + + ToggleWipeDataPin { enable } => { + if !enable { + if self.is_in_decoy_mode() { + return DecoyModeLocalUpdate; + } + return ProceedToSheet(SecuritySheetState::RemoveWipeDataPin); + } + + // check unverified wallets + if let Some(wallet_id) = unverified_wallet_ids.first() { + return ShowAlert(SecurityAlertState::UnverifiedWallets { + wallet_id: wallet_id.clone(), + }); + } + + // PIN is required (biometric only = no PIN) + if self.auth_type() == AuthType::Biometric { + return ShowAlert(SecurityAlertState::NotePinRequired); + } + + // must disable biometric first + if self.auth_type() == AuthType::Both { + return ShowAlert(SecurityAlertState::NoteFaceIdDisablingForWipeMePin); + } + + ShowAlert(SecurityAlertState::ConfirmEnableWipeMePin) + } + + ToggleDecoyPin { enable } => { + if !enable { + if self.is_in_decoy_mode() { + return DecoyModeLocalUpdate; + } + return ProceedToSheet(SecuritySheetState::RemoveDecoyPin); + } + + // PIN is required + if self.auth_type() == AuthType::Biometric { + return ShowAlert(SecurityAlertState::NotePinRequired); + } + + // must disable biometric first + if self.auth_type() == AuthType::Both { + return ShowAlert(SecurityAlertState::NoteFaceIdDisablingForDecoyPin); + } + + ShowAlert(SecurityAlertState::ConfirmDecoyPin) + } + + ChangePin => ProceedToSheet(SecuritySheetState::ChangePin), + } + } + + /// Validate a new PIN doesn't conflict with existing PINs + pub fn validate_new_pin(&self, new_pin: String) -> Option { + if self.check_wipe_data_pin(&new_pin) { + return Some( + "Can't update PIN because it's the same as your wipe data PIN".to_string(), + ); + } + + if self.check_decoy_pin(&new_pin) { + return Some("Can't update PIN because it's the same as your decoy PIN".to_string()); + } + + None + } } impl RustAuthManager { From 3aa085b0847e630bbcbb910feca8fa46972439b4 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 10:17:42 -0600 Subject: [PATCH 19/97] Fix biometric prompt and reset stuck biometric flag --- .../org/bitcoinppl/cove/CoveApplication.kt | 6 +++++ .../org/bitcoinppl/cove/views/LockView.kt | 22 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoveApplication.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoveApplication.kt index 92fad4a4e..e3a3e514c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoveApplication.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoveApplication.kt @@ -68,6 +68,12 @@ class CoveApplication : Application() { private fun handleBackground() { try { val auth = Auth + + // reset biometric flag in case it got stuck from a failed prompt + if (auth.isUsingBiometrics) { + auth.isUsingBiometrics = false + } + if (auth.isAuthEnabled && !auth.isLocked) { Log.d(TAG, "[LIFECYCLE] App going to background, locking") auth.lock() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index 774a90ebe..5203bca8b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -28,11 +28,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -219,9 +223,21 @@ private fun BiometricView( onBiometricTap: () -> Unit, onEnterPinTap: () -> Unit, ) { - // auto-trigger biometric when view appears (like iOS .onAppear) - LaunchedEffect(Unit) { - onBiometricTap() + val lifecycleOwner = LocalLifecycleOwner.current + + // trigger biometric only when Activity is RESUMED (not during onStart) + // BiometricPrompt fails silently if called during onStart, before onResume + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + onBiometricTap() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } } Column( From 4caad856b379cdfb637d8f85b9060f81ebfb53b3 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 11:06:09 -0600 Subject: [PATCH 20/97] Move WalletManager FFI writes to background thread Add wrapper functions to dispatch setWalletMetadata to background threads, preventing potential UI jank from database I/O: - iOS: setWalletMetadata dispatches to rustBridge queue - Android: persistWalletMetadata dispatches to ioScope - Android: Add ioScope for reconcile threading (matches iOS pattern) UI state updates remain on main thread, only the DB write moves. --- .../java/org/bitcoinppl/cove/WalletManager.kt | 20 ++++++++++++++----- ios/Cove/WalletManager.swift | 12 +++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) 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 f8ced6cb3..106627b15 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -30,6 +30,7 @@ class WalletManager : private val tag = "WalletManager" private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val isClosed = AtomicBoolean(false) val id: WalletId @@ -246,7 +247,7 @@ class WalletManager : is WalletManagerReconcileMessage.WalletMetadataChanged -> { walletMetadata = message.v1 - rust.setWalletMetadata(message.v1) + persistWalletMetadata(message.v1) } is WalletManagerReconcileMessage.WalletScannerResponse -> { @@ -282,12 +283,16 @@ class WalletManager : override fun reconcile(message: WalletManagerReconcileMessage) { logDebug("reconcile: $message") - mainScope.launch { apply(message) } + ioScope.launch { + mainScope.launch { apply(message) } + } } override fun reconcileMany(messages: List) { logDebug("reconcile_messages: ${messages.size} messages") - mainScope.launch { messages.forEach { apply(it) } } + ioScope.launch { + mainScope.launch { messages.forEach { apply(it) } } + } } fun dispatch(action: WalletManagerAction) { @@ -295,11 +300,16 @@ class WalletManager : mainScope.launch(Dispatchers.IO) { rust.dispatch(action) } } + private fun persistWalletMetadata(metadata: WalletMetadata) { + ioScope.launch { rust.setWalletMetadata(metadata) } + } + override fun close() { if (!isClosed.compareAndSet(false, true)) return logDebug("Closing WalletManager for $id") - mainScope.cancel() // stop callbacks into Rust - rust.close() // free Rust Arc + ioScope.cancel() + mainScope.cancel() + rust.close() } } diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index 8d661a8de..a73b46bb5 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -183,10 +183,8 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana self.unsignedTransactions = (try? rust.getUnsignedTransactions()) ?? [] case let .walletMetadataChanged(metadata): - withAnimation { - self.walletMetadata = metadata - self.rust.setWalletMetadata(metadata: metadata) - } + withAnimation { self.walletMetadata = metadata } + setWalletMetadata(metadata) case let .walletScannerResponse(scannerResponse): self.logger.debug("walletScannerResponse: \(scannerResponse)") @@ -215,6 +213,12 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana label: "cove.walletmanager.rustbridge", qos: .userInitiated ) + private func setWalletMetadata(_ metadata: WalletMetadata) { + rustBridge.async { [weak self] in + self?.rust.setWalletMetadata(metadata: metadata) + } + } + func reconcile(message: Message) { rustBridge.async { [weak self] in guard let self else { From 78fe49d75448db4aee3417345637b15740baae63 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 11:15:39 -0600 Subject: [PATCH 21/97] Add ioScope threading to Android managers Match iOS reconcile pattern: run FFI callbacks on background thread before dispatching to main thread for UI updates. This prevents potential ANR issues from blocking the main thread during reconcile. Managers updated: - SendFlowManager - CoinControlManager - AppManager - AuthManager - PendingWalletManager - ImportWalletManager --- .../java/org/bitcoinppl/cove/AppManager.kt | 109 +++++++++--------- .../java/org/bitcoinppl/cove/AuthManager.kt | 27 +++-- .../org/bitcoinppl/cove/CoinControlManager.kt | 10 +- .../bitcoinppl/cove/ImportWalletManager.kt | 17 ++- .../bitcoinppl/cove/PendingWalletManager.kt | 21 ++-- .../org/bitcoinppl/cove/SendFlowManager.kt | 10 +- 6 files changed, 113 insertions(+), 81 deletions(-) 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 e982b8728..26c665973 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -25,6 +25,9 @@ class AppManager private constructor() : FfiReconcile { // Scope for UI-bound work; reconcile() hops to Main here private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + // Scope for IO-bound work; FFI calls run here first before dispatching to mainScope + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // rust bridge - not observable internal var rust: FfiApp = FfiApp() private set @@ -513,73 +516,75 @@ class AppManager private constructor() : FfiReconcile { override fun reconcile(message: AppStateReconcileMessage) { logDebug("Reconcile: $message") - // Ensure all Compose state mutations occur on Main - mainScope.launch { - when (message) { - is AppStateReconcileMessage.RouteUpdated -> { - router.updateRoutes(message.v1.toList()) - } + // Run on IO first to avoid blocking main thread on FFI calls, then dispatch to Main for UI updates + ioScope.launch { + mainScope.launch { + when (message) { + is AppStateReconcileMessage.RouteUpdated -> { + router.updateRoutes(message.v1.toList()) + } - is AppStateReconcileMessage.PushedRoute -> { - val newRoutes = (router.routes + message.v1).toList() - router.updateRoutes(newRoutes) - } + is AppStateReconcileMessage.PushedRoute -> { + val newRoutes = (router.routes + message.v1).toList() + router.updateRoutes(newRoutes) + } - is AppStateReconcileMessage.DatabaseUpdated -> { - database = Database() - } + is AppStateReconcileMessage.DatabaseUpdated -> { + database = Database() + } - is AppStateReconcileMessage.ColorSchemeChanged -> { - colorSchemeSelection = message.v1 - } + is AppStateReconcileMessage.ColorSchemeChanged -> { + colorSchemeSelection = message.v1 + } - is AppStateReconcileMessage.SelectedNodeChanged -> { - selectedNode = message.v1 - } + is AppStateReconcileMessage.SelectedNodeChanged -> { + selectedNode = message.v1 + } - is AppStateReconcileMessage.SelectedNetworkChanged -> { - if (previousSelectedNetwork == null) { - previousSelectedNetwork = selectedNetwork + is AppStateReconcileMessage.SelectedNetworkChanged -> { + if (previousSelectedNetwork == null) { + previousSelectedNetwork = selectedNetwork + } + selectedNetwork = message.v1 } - selectedNetwork = message.v1 - } - is AppStateReconcileMessage.DefaultRouteChanged -> { - router.default = message.v1 - router.updateRoutes(message.v2.toList()) - routeId = UUID.randomUUID().toString() - logDebug("Route ID changed to: $routeId") - } + is AppStateReconcileMessage.DefaultRouteChanged -> { + router.default = message.v1 + router.updateRoutes(message.v2.toList()) + routeId = UUID.randomUUID().toString() + logDebug("Route ID changed to: $routeId") + } - is AppStateReconcileMessage.FiatPricesChanged -> { - prices = message.v1 - } + is AppStateReconcileMessage.FiatPricesChanged -> { + prices = message.v1 + } - is AppStateReconcileMessage.FeesChanged -> { - fees = message.v1 - } + is AppStateReconcileMessage.FeesChanged -> { + fees = message.v1 + } - is AppStateReconcileMessage.FiatCurrencyChanged -> { - selectedFiatCurrency = message.v1 + 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() + // refresh fiat values in the wallet manager using IO + walletManager?.let { wm -> + launch(Dispatchers.IO) { + wm.forceWalletScan() + wm.updateWalletBalance() + } } } - } - is AppStateReconcileMessage.AcceptedTerms -> { - isTermsAccepted = true - } + is AppStateReconcileMessage.AcceptedTerms -> { + isTermsAccepted = true + } - is AppStateReconcileMessage.WalletModeChanged -> { - isLoading = true - launch { - kotlinx.coroutines.delay(200) - isLoading = false + is AppStateReconcileMessage.WalletModeChanged -> { + isLoading = true + launch { + kotlinx.coroutines.delay(200) + isLoading = false + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt index 3449fc305..af1bc3d1d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt @@ -28,6 +28,7 @@ class AuthManager private constructor() : AuthManagerReconciler { private val tag = "AuthManager" private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) internal var rust: RustAuthManager = RustAuthManager() private set @@ -200,18 +201,20 @@ class AuthManager private constructor() : AuthManagerReconciler { override fun reconcile(message: AuthManagerReconcileMessage) { logDebug("reconcile: $message") - mainScope.launch { - when (message) { - is AuthManagerReconcileMessage.AuthTypeChanged -> { - type = message.v1 - } - - is AuthManagerReconcileMessage.WipeDataPinChanged -> { - isWipeDataPinEnabled = rust.isWipeDataPinEnabled() - } - - is AuthManagerReconcileMessage.DecoyPinChanged -> { - isDecoyPinEnabled = rust.isDecoyPinEnabled() + ioScope.launch { + mainScope.launch { + when (message) { + is AuthManagerReconcileMessage.AuthTypeChanged -> { + type = message.v1 + } + + is AuthManagerReconcileMessage.WipeDataPinChanged -> { + isWipeDataPinEnabled = rust.isWipeDataPinEnabled() + } + + is AuthManagerReconcileMessage.DecoyPinChanged -> { + isDecoyPinEnabled = rust.isDecoyPinEnabled() + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt index cb1f79081..6bec756a8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/CoinControlManager.kt @@ -30,6 +30,7 @@ class CoinControlManager( private val tag = "CoinControlManager" private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val isClosed = AtomicBoolean(false) var sort by mutableStateOf( @@ -190,12 +191,16 @@ class CoinControlManager( override fun reconcile(message: CoinControlManagerReconcileMessage) { logDebug("reconcile: $message") - mainScope.launch { apply(message) } + ioScope.launch { + mainScope.launch { apply(message) } + } } override fun reconcileMany(messages: List) { logDebug("reconcile_messages: ${messages.size} messages") - mainScope.launch { messages.forEach { apply(it) } } + ioScope.launch { + mainScope.launch { messages.forEach { apply(it) } } + } } fun dispatch(action: CoinControlManagerAction) { @@ -207,6 +212,7 @@ class CoinControlManager( if (!isClosed.compareAndSet(false, true)) return logDebug("Closing CoinControlManager") updateSendFlowManagerTask?.cancel() + ioScope.cancel() mainScope.cancel() // stop callbacks into Rust rust.close() // free Rust Arc } 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 0a55cf7ec..cb59711a5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ImportWalletManager.kt @@ -2,6 +2,10 @@ package org.bitcoinppl.cove import android.util.Log import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* import java.io.Closeable @@ -13,6 +17,8 @@ class ImportWalletManager : Closeable { private val tag = "ImportWalletManager" private val isClosed = AtomicBoolean(false) + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val rust: RustImportWalletManager @@ -24,10 +30,13 @@ class ImportWalletManager : override fun reconcile(message: ImportWalletManagerReconcileMessage) { Log.d(tag, "Reconcile: $message") - - when (message) { - ImportWalletManagerReconcileMessage.NO_OP -> { - // no-op + ioScope.launch { + mainScope.launch { + when (message) { + ImportWalletManagerReconcileMessage.NO_OP -> { + // no-op + } + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/PendingWalletManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/PendingWalletManager.kt index 8c01ba04b..b456551cf 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/PendingWalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/PendingWalletManager.kt @@ -27,6 +27,7 @@ class PendingWalletManager( private val tag = "PendingWalletManager" private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val isClosed = AtomicBoolean(false) val rust: RustPendingWalletManager = RustPendingWalletManager(numberOfWords) @@ -49,13 +50,14 @@ class PendingWalletManager( override fun reconcile(message: PendingWalletManagerReconcileMessage) { logDebug("Reconcile: $message") - mainScope.launch { - when (message) { - is PendingWalletManagerReconcileMessage.Words -> { - numberOfWords = message.v1 - // Fetching words could be blocking; do it on IO then update on Main - val words = withContext(Dispatchers.IO) { rust.bip39Words() } - bip39Words = words + ioScope.launch { + mainScope.launch { + when (message) { + is PendingWalletManagerReconcileMessage.Words -> { + numberOfWords = message.v1 + val words = withContext(Dispatchers.IO) { rust.bip39Words() } + bip39Words = words + } } } } @@ -69,7 +71,8 @@ class PendingWalletManager( override fun close() { if (!isClosed.compareAndSet(false, true)) return logDebug("Closing PendingWalletManager") - mainScope.cancel() // stop callbacks into Rust - rust.close() // free Rust Arc + ioScope.cancel() + mainScope.cancel() + rust.close() } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt index 39cd13413..2295ebf0e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt @@ -31,6 +31,7 @@ class SendFlowManager( // Scope for UI-bound work; reconcile and UI updates run on Main private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val isClosed = AtomicBoolean(false) val id: WalletId = rust.walletId() @@ -246,12 +247,16 @@ class SendFlowManager( override fun reconcile(message: SendFlowManagerReconcileMessage) { logDebug("reconcile: $message") - mainScope.launch { apply(message) } + ioScope.launch { + mainScope.launch { apply(message) } + } } override fun reconcileMany(messages: List) { logDebug("reconcile_messages: ${messages.size} messages") - mainScope.launch { messages.forEach { apply(it) } } + ioScope.launch { + mainScope.launch { messages.forEach { apply(it) } } + } } fun dispatch(action: SendFlowManagerAction) { @@ -286,6 +291,7 @@ class SendFlowManager( if (!isClosed.compareAndSet(false, true)) return logDebug("Closing SendFlowManager for $id") debouncedTask?.cancel() + ioScope.cancel() mainScope.cancel() // stop callbacks into Rust rust.close() // free Rust Arc } From db65b4f25cd6a3c8aec9351268449402037151cb Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:14:02 -0600 Subject: [PATCH 22/97] Fix android locking and cover --- .gitignore | 1 + .../java/org/bitcoinppl/cove/MainActivity.kt | 78 +++++++++++++++++++ .../org/bitcoinppl/cove/views/LockView.kt | 15 +++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1193be376..58b5c1c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ log.txt *.part *.lck *.salive +android/.kotlin/ diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 85d4b1dfd..5d96ee5a2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -5,6 +5,11 @@ import android.net.Uri import android.os.Bundle import android.provider.Settings import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -55,6 +60,45 @@ import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat import org.bitcoinppl.cove_core.types.ColorSchemeSelection class MainActivity : FragmentActivity() { + // view-based privacy cover - updates synchronously (unlike Compose state) + private var privacyCoverView: View? = null + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus && Auth.isAuthEnabled) { + // show cover when window loses focus (fires earlier than onPause) + privacyCoverView?.visibility = View.VISIBLE + // FLAG_SECURE as system-level guarantee content won't be captured + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else if (hasFocus) { + // hide cover when window regains focus (e.g., after biometric prompt) + privacyCoverView?.visibility = View.GONE + // clear FLAG_SECURE to allow screenshots during normal use + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + override fun onPause() { + super.onPause() + // backup: also show cover in onPause in case onWindowFocusChanged didn't fire + if (Auth.isAuthEnabled) { + privacyCoverView?.visibility = View.VISIBLE + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } + } + + override fun onResume() { + super.onResume() + privacyCoverView?.visibility = View.GONE + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge( @@ -172,6 +216,40 @@ class MainActivity : FragmentActivity() { } } } + + // create view-based privacy cover overlay (synchronous updates, no Compose race condition) + setupPrivacyCover() + } + + private fun setupPrivacyCover() { + val iconSize = (144 * resources.displayMetrics.density).toInt() + + val imageView = + ImageView(this).apply { + setImageResource(R.drawable.ic_launcher_foreground) + scaleType = ImageView.ScaleType.FIT_CENTER + } + + val container = + FrameLayout(this).apply { + setBackgroundColor(android.graphics.Color.BLACK) + val params = + FrameLayout.LayoutParams(iconSize, iconSize).apply { + gravity = Gravity.CENTER + } + addView(imageView, params) + visibility = View.GONE + } + + addContentView( + container, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + privacyCoverView = container } companion object { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index 5203bca8b..78c69d65a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -34,9 +34,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -47,6 +44,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.UnlockMode import org.bitcoinppl.cove_core.AuthType @@ -225,7 +225,7 @@ private fun BiometricView( ) { val lifecycleOwner = LocalLifecycleOwner.current - // trigger biometric only when Activity is RESUMED (not during onStart) + // trigger biometric when Activity is RESUMED // BiometricPrompt fails silently if called during onStart, before onResume DisposableEffect(lifecycleOwner) { val observer = @@ -235,6 +235,13 @@ private fun BiometricView( } } lifecycleOwner.lifecycle.addObserver(observer) + + // also trigger immediately if already resumed (fixes race condition where + // composable enters composition after ON_RESUME event already fired) + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + onBiometricTap() + } + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } From 515aeaa3324fc964ef81f5ab99a8e780b4540f27 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:14:05 -0600 Subject: [PATCH 23/97] Update justfile --- justfile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/justfile b/justfile index 848437b68..6689ba0e2 100644 --- a/justfile +++ b/justfile @@ -118,37 +118,37 @@ compile: # build android alias ba := build-android build-android: - cd rust && cargo xtask build-android debug + cd rust && cargo xtask build-android debug && say "done building Android debug" alias bar := build-android-release build-android-release: - cd rust && cargo xtask build-android release + cd rust && cargo xtask build-android release && say "done building Android release" alias ra := run-android run-android profile="debug": cd rust && cargo xtask run-android {{profile}} compile-android: - cd android && ./gradlew assembleDebug + cd android && ./gradlew assembleDebug && say "done building Android debug" # build ios alias bi := build-ios build-ios profile="debug" *flags="": - cd rust && cargo xtask build-ios {{profile}} {{flags}} + cd rust && cargo xtask build-ios {{profile}} {{flags}} && say "done building iOS {{profile}}" alias bir := build-ios-release build-ios-release: - cd rust && cargo xtask build-ios release-smaller --device + cd rust && cargo xtask build-ios release-smaller --device && say "done building iOS release" alias bidd := build-ios-debug-device build-ios-debug-device: - cd rust && cargo xtask build-ios debug --device + cd rust && cargo xtask build-ios debug --device && say "done building iOS debug" run-ios: cd rust && cargo xtask run-ios compile-ios: - cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -arch arm64 build + cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -arch arm64 build && say "done building iOS debug" xtask *args: cd rust && cargo xtask {{args}} From eee0db0eb9e36f89b474ef911c628db3f63b7405 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:14:22 -0600 Subject: [PATCH 24/97] Don't use dispatch in already dispatch --- rust/src/manager/wallet_manager.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 2bf2585c1..5610d689d 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -967,8 +967,8 @@ impl RustWalletManager { let next_index = (current_index + 1) % order.len(); let (fiat_or_btc, unit) = order[next_index]; - self.dispatch(Action::UpdateFiatOrBtc(fiat_or_btc)); - self.dispatch(Action::UpdateUnit(unit)); + metadata.fiat_or_btc = fiat_or_btc; + metadata.selected_unit = unit; } Action::ToggleDetailsExpanded => { From 3e6e3525d8cf7e63164531ee4c75fe01d87641bb Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:14:35 -0600 Subject: [PATCH 25/97] WIP autosizetext --- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index a597ec870..fd7ad9103 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -21,8 +21,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import kotlin.math.floor -import kotlin.math.log10 /** * Text composable that automatically resizes to fit available width @@ -127,22 +125,12 @@ fun BalanceAutoSizeText( val fontFamilyResolver = LocalFontFamilyResolver.current val minFontSize = baseFontSize * minimumScaleFactor - // extract numeric value to count digits (matches iOS algorithm) - val digits = - remember(text) { - val numericText = text.replace(Regex("[^0-9.]"), "") - val number = numericText.toDoubleOrNull() ?: 0.0 - if (number > 0) { - floor(log10(number)).toInt() + 1 - } else { - 1 - } - } - - // calculate font size based on digits: max(baseFontSize - (digits - 1) * 2, minFontSize) - val digitBasedFontSize = - remember(digits, baseFontSize, minFontSize) { - val reduction = (digits - 1) * 2 + // calculate starting font size based on text length + // reduce by 1.5sp per character over 8 chars + val lengthBasedFontSize = + remember(text, baseFontSize, minFontSize) { + val charsOverBase = maxOf(0, text.length - 8) + val reduction = charsOverBase * 1.5f val calculated = baseFontSize.value - reduction maxOf(calculated, minFontSize.value).sp } @@ -154,7 +142,7 @@ fun BalanceAutoSizeText( val finalFontSize = calculateOptimalFontSize( text = text, - maxFontSize = digitBasedFontSize, + maxFontSize = lengthBasedFontSize, minFontSize = minFontSize, maxWidthPx = maxWidthPx, style = style, From b857257e79f1fcffc80fcbe16963911831d77d31 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:14:42 -0600 Subject: [PATCH 26/97] WIP padding changes --- .../cove/wallet_transactions/WalletTransactionsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index c6d77c82d..8a7b79166 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -601,7 +601,7 @@ private fun BalanceWidget( Column( modifier = Modifier.clickable { onToggle() }, - verticalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( text = if (isHidden) "••••••" else secondaryAmount, From 98a052dc4c6697c5f44ada5209fa1a67fb64822c Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:32:29 -0600 Subject: [PATCH 27/97] Improve AutoSizeText accuracy and fix edge cases - Add guard against invalid maxWidthPx (0 or infinity) - Initialize optimalSize to max instead of min to prevent returning minimum font size when constraints aren't ready - Use finer 0.1sp steps instead of 0.5sp for less over-shrinking - Add all style dependencies to remember keys - Remove aggressive character-based pre-reduction in BalanceAutoSizeText, letting binary search find optimal size directly --- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index fd7ad9103..ab929f563 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -61,9 +61,18 @@ fun AutoSizeText( BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } - // calculate optimal font size + // calculate optimal font size with all dependencies val fontSize = - remember(text, maxWidthPx, maxFontSize, minFontSize) { + remember( + text, + maxWidthPx, + maxFontSize, + minFontSize, + style, + fontWeight, + fontStyle, + fontFamily, + ) { calculateOptimalFontSize( text = text, maxFontSize = maxFontSize, @@ -125,33 +134,32 @@ fun BalanceAutoSizeText( val fontFamilyResolver = LocalFontFamilyResolver.current val minFontSize = baseFontSize * minimumScaleFactor - // calculate starting font size based on text length - // reduce by 1.5sp per character over 8 chars - val lengthBasedFontSize = - remember(text, baseFontSize, minFontSize) { - val charsOverBase = maxOf(0, text.length - 8) - val reduction = charsOverBase * 1.5f - val calculated = baseFontSize.value - reduction - maxOf(calculated, minFontSize.value).sp - } - BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } - // calculate optimal font size (no remember - needs fresh calculation based on actual width) + // let binary search find optimal size from baseFontSize val finalFontSize = - calculateOptimalFontSize( - text = text, - maxFontSize = lengthBasedFontSize, - minFontSize = minFontSize, - maxWidthPx = maxWidthPx, - style = style, - fontWeight = fontWeight, - fontStyle = null, - fontFamily = null, - density = density, - fontFamilyResolver = fontFamilyResolver, - ) + remember( + text, + maxWidthPx, + baseFontSize, + minFontSize, + style, + fontWeight, + ) { + calculateOptimalFontSize( + text = text, + maxFontSize = baseFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = null, + fontFamily = null, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + } Text( text = text, @@ -170,7 +178,7 @@ fun BalanceAutoSizeText( } /** - * Calculate optimal font size using binary search + * Calculate optimal font size using binary search with 0.1sp precision */ private fun calculateOptimalFontSize( text: String, @@ -184,12 +192,17 @@ private fun calculateOptimalFontSize( density: Density, fontFamilyResolver: FontFamily.Resolver, ): TextUnit { + // guard against invalid width - return max font size if we can't measure + if (maxWidthPx <= 0 || maxWidthPx == Float.MAX_VALUE) { + return maxFontSize + } + var low = minFontSize.value var high = maxFontSize.value - var optimalSize = minFontSize.value + var optimalSize = maxFontSize.value // start with max, shrink only if needed - // binary search for optimal font size - while (low <= high) { + // binary search for optimal font size with 0.1sp precision + while (high - low > 0.1f) { val mid = (low + high) / 2f val testStyle = style.copy( @@ -214,10 +227,10 @@ private fun calculateOptimalFontSize( if (paragraph.minIntrinsicWidth <= maxWidthPx) { // text fits, try larger size optimalSize = mid - low = mid + 0.5f + low = mid + 0.1f } else { // text doesn't fit, try smaller size - high = mid - 0.5f + high = mid - 0.1f } } From 65834acef5eaa0aef6b082d1f6dd7b57ae70a2c1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:35:31 -0600 Subject: [PATCH 28/97] Fix Android text spacing to match iOS - Add tight text styling to Typography with PlatformTextStyle and LineHeightStyle to remove extra font padding - Improve AutoSizeText binary search precision and add guard for invalid width - Revert BalanceWidget spacing to 6.dp now that typography matches iOS --- .../java/org/bitcoinppl/cove/ui/theme/Type.kt | 71 ++++++++++++++----- .../WalletTransactionsScreen.kt | 2 +- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt index 2206af6fc..85022bac6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt @@ -1,12 +1,21 @@ package org.bitcoinppl.cove.ui.theme import androidx.compose.material3.Typography +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.sp -// Complete Material 3 typography scale +// tight text styling to match iOS spacing behavior +private val tightPlatformStyle = PlatformTextStyle(includeFontPadding = false) +private val tightLineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both +) + +// Complete Material 3 typography scale with iOS-matching tight spacing val Typography = Typography( // Display styles - large, prominent text @@ -15,24 +24,30 @@ val Typography = fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 57.sp, - lineHeight = 64.sp, + lineHeight = 57.sp, letterSpacing = (-0.25).sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), displayMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 45.sp, - lineHeight = 52.sp, + lineHeight = 45.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), displaySmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 36.sp, - lineHeight = 44.sp, + lineHeight = 36.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), // Headline styles - high-emphasis text headlineLarge = @@ -40,24 +55,30 @@ val Typography = fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 32.sp, - lineHeight = 40.sp, + lineHeight = 32.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), headlineMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 28.sp, - lineHeight = 36.sp, + lineHeight = 28.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), headlineSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 24.sp, - lineHeight = 32.sp, + lineHeight = 24.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), // Title styles - medium-emphasis text titleLarge = @@ -65,24 +86,30 @@ val Typography = fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 22.sp, - lineHeight = 28.sp, + lineHeight = 22.sp, letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), titleMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 16.sp, - lineHeight = 24.sp, + lineHeight = 16.sp, letterSpacing = 0.15.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), titleSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 14.sp, - lineHeight = 20.sp, + lineHeight = 14.sp, letterSpacing = 0.1.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), // Body styles - main content text bodyLarge = @@ -90,24 +117,30 @@ val Typography = fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, - lineHeight = 24.sp, + lineHeight = 16.sp, letterSpacing = 0.5.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 14.sp, - lineHeight = 20.sp, + lineHeight = 14.sp, letterSpacing = 0.25.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), bodySmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 12.sp, - lineHeight = 16.sp, + lineHeight = 12.sp, letterSpacing = 0.4.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), // Label styles - UI elements, buttons labelLarge = @@ -115,23 +148,29 @@ val Typography = fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 14.sp, - lineHeight = 20.sp, + lineHeight = 14.sp, letterSpacing = 0.1.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), labelMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 12.sp, - lineHeight = 16.sp, + lineHeight = 12.sp, letterSpacing = 0.5.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, - lineHeight = 16.sp, + lineHeight = 11.sp, letterSpacing = 0.5.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, ), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 8a7b79166..c6d77c82d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -601,7 +601,7 @@ private fun BalanceWidget( Column( modifier = Modifier.clickable { onToggle() }, - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( text = if (isHidden) "••••••" else secondaryAmount, From 240e56ffa3fe3deebcb6f968d7c9a10198eccac1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:53:57 -0600 Subject: [PATCH 29/97] Avoid dispatch call in validate_metadata Directly modify metadata instead of calling dispatch to prevent potential deadlock if this function is ever called from within a dispatch handler. --- rust/src/manager/wallet_manager.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 5610d689d..8ae1d1eee 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -705,16 +705,28 @@ impl RustWalletManager { #[uniffi::method] pub fn validate_metadata(&self) { - if self.metadata.read().name.trim().is_empty() { - let name = self - .metadata - .read() + let name = { + let metadata = self.metadata.read(); + if !metadata.name.trim().is_empty() { + return; + } + metadata .master_fingerprint .as_deref() .map(Fingerprint::as_uppercase) - .unwrap_or_else(|| "Unnamed Wallet".to_string()); + .unwrap_or_else(|| "Unnamed Wallet".to_string()) + }; + + let metadata = { + let mut metadata = self.metadata.write(); + metadata.name = name; + metadata.clone() + }; - self.dispatch(Action::UpdateName(name)); + self.reconciler.send(Message::WalletMetadataChanged(metadata.clone())); + + if let Err(error) = Database::global().wallets.update_wallet_metadata(metadata) { + error!("Unable to update wallet metadata: {error:?}") } } From 7772b627cf8edd3d360b37e1ad33a01f453b9827 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 12:54:20 -0600 Subject: [PATCH 30/97] Fix Android send flow unit switching (sats/btc) Add observer for selectedUnit changes that notifies SendFlowManager when unit changes, mirroring iOS .onChange behavior. Without this, the dropdown would update walletMetadata but the Rust backend was never notified to recalculate displayed amounts. --- .../java/org/bitcoinppl/cove/SendFlowContainer.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index 01e91d0b5..5c92850c0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -116,6 +116,17 @@ fun SendFlowContainer( } } + // observe unit changes and notify send flow manager (mirrors iOS .onChange) + var previousUnit by remember { mutableStateOf(wm.walletMetadata?.selectedUnit) } + LaunchedEffect(wm.walletMetadata?.selectedUnit) { + val currentUnit = wm.walletMetadata?.selectedUnit + val oldUnit = previousUnit + if (oldUnit != null && currentUnit != null && oldUnit != currentUnit) { + sfm.dispatch(SendFlowManagerAction.NotifySelectedUnitedChanged(oldUnit, currentUnit)) + } + previousUnit = currentUnit + } + SendFlowRouteToScreen( app = app, sendRoute = sendRoute, From 1a9639b50f8b98c818bde4ee499468f5ff51435d Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 22:45:21 -0600 Subject: [PATCH 31/97] Fix privacy cover false positives from internal popups Move privacy cover visibility handling to onPause/onResume only, while keeping FLAG_SECURE toggle in onWindowFocusChanged. This prevents internal popups like DropdownMenu from triggering the privacy cover. --- .../src/main/java/org/bitcoinppl/cove/MainActivity.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 5d96ee5a2..09b1e70e2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -65,25 +65,21 @@ class MainActivity : FragmentActivity() { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) + // only toggle FLAG_SECURE here (invisible to user) + // privacy cover is handled in onPause/onResume to avoid false positives from internal popups if (!hasFocus && Auth.isAuthEnabled) { - // show cover when window loses focus (fires earlier than onPause) - privacyCoverView?.visibility = View.VISIBLE - // FLAG_SECURE as system-level guarantee content won't be captured window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) } else if (hasFocus) { - // hide cover when window regains focus (e.g., after biometric prompt) - privacyCoverView?.visibility = View.GONE - // clear FLAG_SECURE to allow screenshots during normal use window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } override fun onPause() { super.onPause() - // backup: also show cover in onPause in case onWindowFocusChanged didn't fire + // show cover only on actual app transitions (not internal popups like DropdownMenu) if (Auth.isAuthEnabled) { privacyCoverView?.visibility = View.VISIBLE window.setFlags( From c0993bc698b23e568909ebcac20361de639d6cc3 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 22:47:40 -0600 Subject: [PATCH 32/97] Add AutoSizeTextField for send amount input Add new AutoSizeTextField composable that provides an editable text field with auto-sizing, mimicking iOS TextField behavior with minimumScaleFactor. Use it in SendScreen AmountWidget to handle long amounts gracefully. --- .../org/bitcoinppl/cove/send/SendScreen.kt | 16 ++-- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index 22cd855fb..a3375f06b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.AutoSizeTextField import org.bitcoinppl.cove.views.ImageButton @Preview() @@ -295,20 +296,17 @@ private fun AmountWidget( verticalAlignment = Alignment.Bottom, ) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - BasicTextField( + AutoSizeTextField( value = amount, onValueChange = { newValue -> amount = newValue onAmountChanged(newValue) }, - textStyle = - TextStyle( - color = MaterialTheme.colorScheme.onSurface, - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Right, - ), - singleLine = true, + maxFontSize = 48.sp, + minimumScaleFactor = 0.01f, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Right, modifier = Modifier.fillMaxWidth(), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index ab929f563..30549e5ce 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -2,12 +2,15 @@ package org.bitcoinppl.cove.views import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.TextLayoutResult @@ -15,6 +18,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -177,6 +181,79 @@ fun BalanceAutoSizeText( } } +/** + * Editable text field that automatically resizes to fit available width + * Mimics iOS TextField with minimumScaleFactor behavior + * + * @param value The current text value + * @param onValueChange Callback when text changes + * @param modifier Modifier for the text field + * @param maxFontSize Maximum font size (will shrink from this) + * @param minimumScaleFactor Minimum scale factor (0.0 to 1.0). Text will shrink to this percentage + * @param color Text color + * @param fontWeight Font weight + * @param textAlign Text alignment + */ +@Composable +fun AutoSizeTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + maxFontSize: TextUnit = 48.sp, + minimumScaleFactor: Float = 0.01f, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, + textAlign: TextAlign? = null, +) { + val density = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + val minFontSize = maxFontSize * minimumScaleFactor + val style = LocalTextStyle.current + + BoxWithConstraints(modifier = modifier) { + val maxWidthPx = with(density) { maxWidth.toPx() } + + val fontSize = + remember( + value, + maxWidthPx, + maxFontSize, + minFontSize, + style, + fontWeight, + ) { + calculateOptimalFontSize( + text = value, + maxFontSize = maxFontSize, + minFontSize = minFontSize, + maxWidthPx = maxWidthPx, + style = style, + fontWeight = fontWeight, + fontStyle = null, + fontFamily = null, + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = + TextStyle( + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign ?: TextAlign.Unspecified, + ), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + cursorBrush = SolidColor(color), + modifier = Modifier.fillMaxWidth(), + ) + } +} + /** * Calculate optimal font size using binary search with 0.1sp precision */ From 55224a15c1f67a1b225ad1fbd62b76aaaa8d5451 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 28 Nov 2025 22:47:52 -0600 Subject: [PATCH 33/97] Shorten justfile say commands --- justfile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/justfile b/justfile index 6689ba0e2..6262d3bfd 100644 --- a/justfile +++ b/justfile @@ -118,37 +118,37 @@ compile: # build android alias ba := build-android build-android: - cd rust && cargo xtask build-android debug && say "done building Android debug" + cd rust && cargo xtask build-android debug && say "done android" alias bar := build-android-release build-android-release: - cd rust && cargo xtask build-android release && say "done building Android release" + cd rust && cargo xtask build-android release && say "done android release" alias ra := run-android run-android profile="debug": cd rust && cargo xtask run-android {{profile}} compile-android: - cd android && ./gradlew assembleDebug && say "done building Android debug" + cd android && ./gradlew assembleDebug # build ios alias bi := build-ios build-ios profile="debug" *flags="": - cd rust && cargo xtask build-ios {{profile}} {{flags}} && say "done building iOS {{profile}}" + cd rust && cargo xtask build-ios {{profile}} {{flags}} && say "done ios" alias bir := build-ios-release build-ios-release: - cd rust && cargo xtask build-ios release-smaller --device && say "done building iOS release" + cd rust && cargo xtask build-ios release-smaller --device && say "done ios release" alias bidd := build-ios-debug-device build-ios-debug-device: - cd rust && cargo xtask build-ios debug --device && say "done building iOS debug" + cd rust && cargo xtask build-ios debug --device && say "done ios device" run-ios: cd rust && cargo xtask run-ios compile-ios: - cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -arch arm64 build && say "done building iOS debug" + cd ios && xcodebuild -scheme Cove -sdk iphonesimulator -arch arm64 build xtask *args: cd rust && cargo xtask {{args}} From 6e3c523d374a250327b532b01e57aada522ed3a7 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 1 Dec 2025 20:06:04 -0600 Subject: [PATCH 34/97] Add fiat/BTC toggle and secondary unit to send flow --- .../org/bitcoinppl/cove/SendFlowContainer.kt | 8 +++ .../org/bitcoinppl/cove/send/SendScreen.kt | 58 ++++++++++++++++--- .../java/org/bitcoinppl/cove/ui/theme/Type.kt | 9 +-- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index 5c92850c0..e6b086de6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -186,6 +186,9 @@ private fun SendFlowRouteToScreen( } walletManager.dispatch(WalletManagerAction.UpdateUnit(bitcoinUnit)) }, + onToggleFiatOrBtc = { + walletManager.dispatch(WalletManagerAction.ToggleFiatOrBtc) + }, isFiatMode = walletManager.walletMetadata?.fiatOrBtc == FiatOrBtc.FIAT, isBalanceHidden = !(walletManager.walletMetadata?.sensitiveVisible ?: true), balanceAmount = walletManager.amountFmt(walletManager.balance.spendable()), @@ -207,6 +210,11 @@ private fun SendFlowRouteToScreen( FiatOrBtc.FIAT -> sendFlowManager.sendAmountBtc else -> sendFlowManager.sendAmountFiat }, + secondaryUnit = + when (walletManager.walletMetadata?.fiatOrBtc) { + FiatOrBtc.FIAT -> walletManager.unit + else -> "" + }, initialAddress = sendFlowManager.enteringAddress, accountShort = walletManager.walletMetadata diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index a3375f06b..4f9e4959b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -79,6 +79,7 @@ fun SendScreen( onChangeSpeed: () -> Unit, onToggleBalanceVisibility: () -> Unit = {}, onUnitChange: (String) -> Unit = {}, + onToggleFiatOrBtc: () -> Unit = {}, isFiatMode: Boolean = false, isBalanceHidden: Boolean = false, balanceAmount: String, @@ -86,6 +87,7 @@ fun SendScreen( amountText: String, amountDenomination: String, dollarEquivalentText: String, + secondaryUnit: String = "", initialAddress: String, accountShort: String, feeEta: String, @@ -159,8 +161,10 @@ fun SendScreen( initialAmount = amountText, denomination = amountDenomination, dollarText = dollarEquivalentText, + secondaryUnit = secondaryUnit, onAmountChanged = onAmountChanged, onUnitChange = onUnitChange, + onToggleFiatOrBtc = onToggleFiatOrBtc, isFiatMode = isFiatMode, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) @@ -262,13 +266,25 @@ private fun AmountWidget( initialAmount: String, denomination: String, dollarText: String, + secondaryUnit: String = "", onAmountChanged: (String) -> Unit, onUnitChange: (String) -> Unit = {}, + onToggleFiatOrBtc: () -> Unit = {}, isFiatMode: Boolean = false, ) { var amount by remember { mutableStateOf(initialAmount) } var showUnitMenu by remember { mutableStateOf(false) } + // offset to compensate for unit dropdown (matches iOS) + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp.dp + val amountOffset = + if (isFiatMode) { + 0.dp + } else { + if (denomination.lowercase() == "btc") screenWidthDp * 0.10f else screenWidthDp * 0.11f + } + // bidirectional sync: update local state when parent state changes androidx.compose.runtime.LaunchedEffect(initialAmount) { if (amount != initialAmount) { @@ -306,8 +322,8 @@ private fun AmountWidget( minimumScaleFactor = 0.01f, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Right, - modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().offset(x = amountOffset), ) } Spacer(Modifier.width(32.dp)) @@ -362,13 +378,37 @@ private fun AmountWidget( } } Spacer(Modifier.height(8.dp)) - Text( - dollarText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) + Row( + modifier = + Modifier + .fillMaxWidth() + .then( + // full width tap area in fiat mode (no dropdown to conflict with) + if (isFiatMode) Modifier.clickable(onClick = onToggleFiatOrBtc) else Modifier, + ), + horizontalArrangement = Arrangement.Center, + ) { + Row( + modifier = + // limited tap area in BTC mode to avoid dropdown conflict + if (!isFiatMode) Modifier.clickable(onClick = onToggleFiatOrBtc) else Modifier, + horizontalArrangement = Arrangement.Center, + ) { + Text( + dollarText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + if (isFiatMode && secondaryUnit.isNotEmpty()) { + Spacer(Modifier.width(4.dp)) + Text( + secondaryUnit, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + } + } + } Spacer(Modifier.height(24.dp)) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt index 85022bac6..68f51ead7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt @@ -10,10 +10,11 @@ import androidx.compose.ui.unit.sp // tight text styling to match iOS spacing behavior private val tightPlatformStyle = PlatformTextStyle(includeFontPadding = false) -private val tightLineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.Both -) +private val tightLineHeightStyle = + LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ) // Complete Material 3 typography scale with iOS-matching tight spacing val Typography = From b7ac233936c4a1c18b33e9ff83bb66aff4d3d6ae Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 09:58:08 -0600 Subject: [PATCH 35/97] Fix Android SendFlow button visibility and match iOS styling - Add theme-aware midnightBtn() color function (light: #1C2536, dark: #4A4A4D) - Fix SendScreen Next button: now visible in both light and dark mode - Fix HardwareExportScreen Export/Import buttons: use light gray background with dark text to match iOS btnPrimary styling - Restructure More Details sheet to match iOS: account section, address, network fee (with >20% warning), they'll receive, you'll pay - Add tappable address to show input/output details from More Details sheet --- .../bitcoinppl/cove/HardwareExportScreen.kt | 136 ++++++++++++++++-- .../org/bitcoinppl/cove/send/SendScreen.kt | 70 +++++++-- .../org/bitcoinppl/cove/ui/theme/Color.kt | 11 ++ 3 files changed, 191 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt index 23ff2ea29..f81893f69 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt @@ -67,6 +67,8 @@ import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.midnightBtn import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* import java.io.File @@ -352,6 +354,7 @@ fun HardwareExportScreen( walletManager = walletManager, details = details, onDismiss = { sheetState = null }, + onShowInputOutput = { sheetState = SheetState.InputOutputDetails }, ) } } @@ -679,7 +682,8 @@ private fun SignTransactionSection( modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, + containerColor = CoveColor.btnPrimary, + contentColor = CoveColor.midnightBlue, ), ) { Icon( @@ -699,7 +703,8 @@ private fun SignTransactionSection( modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, + containerColor = CoveColor.btnPrimary, + contentColor = CoveColor.midnightBlue, ), ) { Icon( @@ -752,31 +757,134 @@ private fun TransactionDetailsSheet( walletManager: WalletManager, details: ConfirmDetails, onDismiss: () -> Unit, + onShowInputOutput: () -> Unit, ) { + val metadata = walletManager.walletMetadata + val feePercentage = details.feePercentage() + Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + // title Text( - text = "Transaction Details", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + text = "More Details", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.align(Alignment.CenterHorizontally), ) - DetailRow("Transaction ID", details.id().asHashString()) - DetailRow("Sending Amount", walletManager.amountFmtUnit(details.sendingAmount())) - DetailRow("Network Fee", walletManager.amountFmtUnit(details.feeTotal())) - DetailRow("Total", walletManager.amountFmtUnit(details.spendingAmount())) - DetailRow("Fee Rate", "${details.feeRate().satPerVb()} sat/vB") + // account section + AccountSection(metadata) - TextButton( + HorizontalDivider() + + // details section + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // address row (tappable) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onShowInputOutput), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Address", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Text( + text = details.sendingTo().spacedOut(), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f).padding(start = 16.dp), + maxLines = 3, + ) + } + + // network fee row (with warning styling if >20%) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Network Fee", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Text( + text = walletManager.amountFmtUnit(details.feeTotal()), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (feePercentage > 20u) FontWeight.Bold else FontWeight.Medium, + color = + if (feePercentage > 20u) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + }, + ) + } + + // they'll receive row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "They'll receive", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = walletManager.amountFmtUnit(details.sendingAmount()), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + } + + // you'll pay row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "You'll pay", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = walletManager.amountFmtUnit(details.spendingAmount()), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // close button with midnightBtn styling (matching iOS) + Button( onClick = onDismiss, - modifier = Modifier.align(Alignment.End), + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = midnightBtn(), + contentColor = Color.White, + ), + shape = RoundedCornerShape(10.dp), ) { - Text("Close") + Text( + text = "Close", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(vertical = 4.dp), + ) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index 4f9e4959b..aa3a7e2d0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -11,6 +11,7 @@ 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.ArrowDropDown +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Visibility @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.midnightBtn import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.AutoSizeTextField import org.bitcoinppl.cove.views.ImageButton @@ -77,9 +79,12 @@ fun SendScreen( onNext: () -> Unit, onScanQr: () -> Unit, onChangeSpeed: () -> Unit, + onClearAmount: () -> Unit = {}, onToggleBalanceVisibility: () -> Unit = {}, onUnitChange: (String) -> Unit = {}, onToggleFiatOrBtc: () -> Unit = {}, + onSanitizeBtcAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, + onSanitizeFiatAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, isFiatMode: Boolean = false, isBalanceHidden: Boolean = false, balanceAmount: String, @@ -163,8 +168,11 @@ fun SendScreen( dollarText = dollarEquivalentText, secondaryUnit = secondaryUnit, onAmountChanged = onAmountChanged, + onClearAmount = onClearAmount, onUnitChange = onUnitChange, onToggleFiatOrBtc = onToggleFiatOrBtc, + onSanitizeBtcAmount = onSanitizeBtcAmount, + onSanitizeFiatAmount = onSanitizeFiatAmount, isFiatMode = isFiatMode, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) @@ -188,7 +196,7 @@ fun SendScreen( onClick = onNext, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = midnightBtn(), contentColor = Color.White, ), modifier = @@ -268,12 +276,16 @@ private fun AmountWidget( dollarText: String, secondaryUnit: String = "", onAmountChanged: (String) -> Unit, + onClearAmount: () -> Unit = {}, onUnitChange: (String) -> Unit = {}, onToggleFiatOrBtc: () -> Unit = {}, + onSanitizeBtcAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, + onSanitizeFiatAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, isFiatMode: Boolean = false, ) { var amount by remember { mutableStateOf(initialAmount) } var showUnitMenu by remember { mutableStateOf(false) } + var textWidth by remember { mutableStateOf(0.dp) } // offset to compensate for unit dropdown (matches iOS) val configuration = LocalConfiguration.current @@ -315,8 +327,19 @@ private fun AmountWidget( AutoSizeTextField( value = amount, onValueChange = { newValue -> - amount = newValue - onAmountChanged(newValue) + val oldValue = amount + // sanitize synchronously before updating local state (matches iOS pattern) + val sanitized = + if (isFiatMode) { + onSanitizeFiatAmount(oldValue, newValue) ?: newValue + } else { + onSanitizeBtcAmount(oldValue, newValue) ?: newValue + } + // only update if changed + if (sanitized != oldValue) { + amount = sanitized + onAmountChanged(sanitized) + } }, maxFontSize = 48.sp, minimumScaleFactor = 0.01f, @@ -324,7 +347,31 @@ private fun AmountWidget( fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().offset(x = amountOffset), + onTextWidthChanged = { width -> textWidth = width }, ) + // clear button: positioned at top, horizontally follows the text's right edge + // text is centered with an offset, so X position = offset + half text width + small margin + if (amount.isNotEmpty()) { + IconButton( + onClick = { + amount = "" + onAmountChanged("") + onClearAmount() + }, + modifier = + Modifier + .align(Alignment.TopCenter) + .offset(x = amountOffset + (textWidth / 2) + 16.dp) + .size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear amount", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } } Spacer(Modifier.width(32.dp)) Box { @@ -379,19 +426,18 @@ private fun AmountWidget( } Spacer(Modifier.height(8.dp)) Row( - modifier = - Modifier - .fillMaxWidth() - .then( - // full width tap area in fiat mode (no dropdown to conflict with) - if (isFiatMode) Modifier.clickable(onClick = onToggleFiatOrBtc) else Modifier, - ), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { Row( modifier = - // limited tap area in BTC mode to avoid dropdown conflict - if (!isFiatMode) Modifier.clickable(onClick = onToggleFiatOrBtc) else Modifier, + Modifier + .clickable(onClick = onToggleFiatOrBtc) + .padding(vertical = 8.dp) + .then( + // add horizontal padding in fiat mode (no dropdown to conflict with) + if (isFiatMode) Modifier.padding(horizontal = 24.dp) else Modifier, + ), horizontalArrangement = Arrangement.Center, ) { Text( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt index 4e55df8b5..917a0fa81 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt @@ -1,5 +1,7 @@ package org.bitcoinppl.cove.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color as ComposeColor /** @@ -65,3 +67,12 @@ object CoveColor { val SuccessGreen = ComposeColor(0xFF4CAF50) val ErrorRed = ComposeColor(0xFFF44336) } + +/** + * Theme-aware midnight button color matching iOS midnightBtn color asset. + * Light mode: dark navy (#1C2536) + * Dark mode: medium gray (#4A4A4D) + */ +@Composable +fun midnightBtn(): ComposeColor = + if (isSystemInDarkTheme()) CoveColor.midnightBtnDark else CoveColor.midnightBlue From bd214f45c07eed39af1f250d66e6a17f6a1a9f73 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:02:33 -0600 Subject: [PATCH 36/97] Fix Android send flow cursor position and add dynamic clear button - Fix cursor jumping left when formatting commas are added by using TextFieldValue internally in AutoSizeTextField to control cursor position - Add synchronous sanitization via sanitizeBtcEnteringAmount/FiatEnteringAmount before updating state (matches iOS pattern) - Guard against redundant dispatches in SendFlowManager - Add text width callback to AutoSizeTextField for positioning related elements - Position clear button at top, horizontally following text's right edge --- .../org/bitcoinppl/cove/SendFlowContainer.kt | 130 ++++++++++++++++++ .../org/bitcoinppl/cove/SendFlowManager.kt | 14 +- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 130 +++++++++++++++++- 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index e6b086de6..cb72cdd4b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -19,6 +22,7 @@ import androidx.compose.ui.Modifier import kotlinx.coroutines.launch import org.bitcoinppl.cove.send.SendScreen import org.bitcoinppl.cove.send.send_confirmation.SendConfirmationScreen +import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* @@ -127,6 +131,47 @@ fun SendFlowContainer( previousUnit = currentUnit } + // observe fiatOrBtc changes and notify send flow manager (mirrors iOS .onChange) + var previousFiatOrBtc by remember { mutableStateOf(wm.walletMetadata?.fiatOrBtc) } + LaunchedEffect(wm.walletMetadata?.fiatOrBtc) { + val currentFiatOrBtc = wm.walletMetadata?.fiatOrBtc + val oldFiatOrBtc = previousFiatOrBtc + if (oldFiatOrBtc != null && currentFiatOrBtc != null && oldFiatOrBtc != currentFiatOrBtc) { + sfm.dispatch(SendFlowManagerAction.NotifyBtcOrFiatChanged(oldFiatOrBtc, currentFiatOrBtc)) + } + previousFiatOrBtc = currentFiatOrBtc + } + + // observe app prices changes and notify send flow manager (mirrors iOS .onChange) + LaunchedEffect(app.prices) { + app.prices?.let { prices -> + sfm.dispatch(SendFlowManagerAction.NotifyPricesChanged(prices)) + } + } + + // observe focus field changes and notify send flow manager (mirrors iOS .onChange) + var previousFocusField by remember { mutableStateOf(presenter.focusField) } + LaunchedEffect(presenter.focusField) { + val currentFocusField = presenter.focusField + val oldFocusField = previousFocusField + if (oldFocusField != currentFocusField) { + sfm.dispatch(SendFlowManagerAction.NotifyFocusFieldChanged(oldFocusField, currentFocusField)) + } + previousFocusField = currentFocusField + } + + // observe auth lock state changes (mirrors iOS .onChange for auth.lockState) + LaunchedEffect(Auth.isLocked) { + if (!Auth.isLocked) { + // after unlock, validate and focus appropriate field + if (!sfm.rust.validateAmount()) { + sfm.dispatch(SendFlowManagerAction.ChangeSetAmountFocusField(SetAmountFocusField.AMOUNT)) + } else if (!sfm.rust.validateAddress()) { + sfm.dispatch(SendFlowManagerAction.ChangeSetAmountFocusField(SetAmountFocusField.ADDRESS)) + } + } + } + SendFlowRouteToScreen( app = app, sendRoute = sendRoute, @@ -150,6 +195,7 @@ fun SendFlowContainer( /** * routes SendRoute to appropriate screen */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFlowRouteToScreen( app: AppManager, @@ -174,6 +220,9 @@ private fun SendFlowRouteToScreen( onChangeSpeed = { presenter.sheetState = TaggedItem(SendFlowPresenter.SheetState.Fee) }, + onClearAmount = { + sendFlowManager.dispatch(SendFlowManagerAction.ClearSendAmount) + }, onToggleBalanceVisibility = { walletManager.dispatch(WalletManagerAction.ToggleSensitiveVisibility) }, @@ -189,6 +238,12 @@ private fun SendFlowRouteToScreen( onToggleFiatOrBtc = { walletManager.dispatch(WalletManagerAction.ToggleFiatOrBtc) }, + onSanitizeBtcAmount = { oldValue, newValue -> + sendFlowManager.rust.sanitizeBtcEnteringAmount(oldValue, newValue) + }, + onSanitizeFiatAmount = { oldValue, newValue -> + sendFlowManager.rust.sanitizeFiatEnteringAmount(oldValue, newValue) + }, isFiatMode = walletManager.walletMetadata?.fiatOrBtc == FiatOrBtc.FIAT, isBalanceHidden = !(walletManager.walletMetadata?.sensitiveVisible ?: true), balanceAmount = walletManager.amountFmt(walletManager.balance.spendable()), @@ -244,6 +299,58 @@ private fun SendFlowRouteToScreen( sendFlowManager.enteringAddress = newAddress }, ) + + // handle sheets for SendScreen + presenter.sheetState?.let { taggedSheet -> + when (taggedSheet.item) { + is SendFlowPresenter.SheetState.Qr -> { + ModalBottomSheet( + onDismissRequest = { presenter.sheetState = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + QrCodeScanView( + onScanned = { stringOrData -> + presenter.sheetState = null + val scannedString = + when (stringOrData) { + is StringOrData.String -> stringOrData.v1 + is StringOrData.Data -> stringOrData.v1.toString(Charsets.UTF_8) + } + sendFlowManager.enteringAddress = scannedString + }, + onDismiss = { presenter.sheetState = null }, + app = app, + ) + } + } + is SendFlowPresenter.SheetState.Fee -> { + sendFlowManager.feeRateOptions?.let { feeOptions -> + sendFlowManager.selectedFeeRate?.let { selectedRate -> + FeeRateSelectorSheet( + app = app, + walletManager = walletManager, + sendFlowManager = sendFlowManager, + presenter = presenter, + feeOptions = feeOptions, + selectedOption = selectedRate, + onSelectFee = { newFeeOption -> + sendFlowManager.dispatch( + SendFlowManagerAction.SelectFeeRate(newFeeOption), + ) + }, + onUpdateFeeOptions = { newOptions -> + sendFlowManager.dispatch( + SendFlowManagerAction.ChangeFeeRateOptions(newOptions), + ) + }, + onDismiss = { presenter.sheetState = null }, + ) + } + } + } + else -> {} + } + } } is SendRoute.CoinControlSetAmount -> { org.bitcoinppl.cove.send.CoinControlSetAmountScreen( @@ -294,6 +401,7 @@ private fun SendFlowRouteToScreen( totalSpendingFiat = sendFlowManager.totalSpentInFiat, utxoCount = sendRoute.utxos.size, utxos = sendRoute.utxos, + app = app, sendFlowManager = sendFlowManager, walletManager = walletManager, presenter = presenter, @@ -311,6 +419,28 @@ private fun SendFlowRouteToScreen( var showErrorAlert by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + // lock on appear for hot wallets (mirrors iOS .onAppear) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(50) + if (walletManager.walletMetadata?.walletType == WalletType.HOT) { + Auth.lock() + } + } + + // timed unlock on disappear (mirrors iOS .onDisappear) + DisposableEffect(Unit) { + onDispose { + val lockedAt = Auth.lockedAt ?: return@onDispose + val sinceLocked = + java.time.Instant + .now() + .epochSecond - lockedAt.epochSecond + if (sinceLocked < 5) { + Auth.unlock() + } + } + } + SendConfirmationScreen( onBack = { app.popRoute() }, sendState = sendState, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt index 2295ebf0e..0896c4bbf 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowManager.kt @@ -114,18 +114,24 @@ class SendFlowManager( /** * update entering BTC amount with debounced dispatch + * only dispatches if value actually changed (matches iOS pattern) */ fun updateEnteringBtcAmount(value: String) { - enteringBtcAmount = value - debouncedDispatch(SendFlowManagerAction.NotifyEnteringBtcAmountChanged(value)) + if (enteringBtcAmount != value) { + enteringBtcAmount = value + debouncedDispatch(SendFlowManagerAction.NotifyEnteringBtcAmountChanged(value)) + } } /** * update entering fiat amount with debounced dispatch + * only dispatches if value actually changed (matches iOS pattern) */ fun updateEnteringFiatAmount(value: String) { - enteringFiatAmount = value - debouncedDispatch(SendFlowManagerAction.NotifyEnteringFiatAmountChanged(value)) + if (enteringFiatAmount != value) { + enteringFiatAmount = value + debouncedDispatch(SendFlowManagerAction.NotifyEnteringFiatAmountChanged(value)) + } } /** diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 30549e5ce..305949c85 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -7,18 +7,23 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.graphics.SolidColor import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -185,6 +190,9 @@ fun BalanceAutoSizeText( * Editable text field that automatically resizes to fit available width * Mimics iOS TextField with minimumScaleFactor behavior * + * Uses TextFieldValue internally to properly manage cursor position when + * the text is modified externally (e.g., formatting with commas). + * * @param value The current text value * @param onValueChange Callback when text changes * @param modifier Modifier for the text field @@ -193,6 +201,7 @@ fun BalanceAutoSizeText( * @param color Text color * @param fontWeight Font weight * @param textAlign Text alignment + * @param onTextWidthChanged Callback with measured text width in Dp (for positioning related elements) */ @Composable fun AutoSizeTextField( @@ -204,26 +213,43 @@ fun AutoSizeTextField( color: Color = Color.Unspecified, fontWeight: FontWeight? = null, textAlign: TextAlign? = null, + onTextWidthChanged: ((androidx.compose.ui.unit.Dp) -> Unit)? = null, ) { val density = LocalDensity.current val fontFamilyResolver = LocalFontFamilyResolver.current val minFontSize = maxFontSize * minimumScaleFactor val style = LocalTextStyle.current + // use TextFieldValue internally to control cursor position + // track the last external value to detect when it changes + var lastExternalValue by remember { mutableStateOf(value) } + var textFieldValue by remember { + mutableStateOf(TextFieldValue(value, TextRange(value.length))) + } + + // sync from external value changes (e.g., formatting), keeping cursor at end + // this runs during composition for immediate effect, not in a side effect + if (value != lastExternalValue) { + lastExternalValue = value + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(value, TextRange(value.length)) + } + } + BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } - val fontSize = + val (fontSize, textWidthPx) = remember( - value, + textFieldValue.text, maxWidthPx, maxFontSize, minFontSize, style, fontWeight, ) { - calculateOptimalFontSize( - text = value, + calculateOptimalFontSizeAndWidth( + text = textFieldValue.text, maxFontSize = maxFontSize, minFontSize = minFontSize, maxWidthPx = maxWidthPx, @@ -236,9 +262,18 @@ fun AutoSizeTextField( ) } + // report text width in Dp + val textWidthDp = with(density) { textWidthPx.toDp() } + onTextWidthChanged?.invoke(textWidthDp) + BasicTextField( - value = value, - onValueChange = onValueChange, + value = textFieldValue, + onValueChange = { newValue -> + textFieldValue = newValue + if (newValue.text != value) { + onValueChange(newValue.text) + } + }, textStyle = TextStyle( color = color, @@ -313,3 +348,86 @@ private fun calculateOptimalFontSize( return optimalSize.sp } + +/** + * Calculate optimal font size and text width using binary search with 0.1sp precision + * Returns a Pair of (fontSize, textWidthPx) + */ +private fun calculateOptimalFontSizeAndWidth( + text: String, + maxFontSize: TextUnit, + minFontSize: TextUnit, + maxWidthPx: Float, + style: TextStyle, + fontWeight: FontWeight?, + fontStyle: FontStyle?, + fontFamily: FontFamily?, + density: Density, + fontFamilyResolver: FontFamily.Resolver, +): Pair { + // guard against invalid width - return max font size if we can't measure + if (maxWidthPx <= 0 || maxWidthPx == Float.MAX_VALUE) { + return Pair(maxFontSize, 0f) + } + + var low = minFontSize.value + var high = maxFontSize.value + var optimalSize = maxFontSize.value // start with max, shrink only if needed + var textWidth = 0f + + // binary search for optimal font size with 0.1sp precision + while (high - low > 0.1f) { + val mid = (low + high) / 2f + val testStyle = + style.copy( + fontSize = mid.sp, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + ) + + // measure text width at this font size + val paragraph = + androidx.compose.ui.text.Paragraph( + text = text, + style = testStyle, + constraints = + androidx.compose.ui.unit + .Constraints(maxWidth = Int.MAX_VALUE), + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + + if (paragraph.minIntrinsicWidth <= maxWidthPx) { + // text fits, try larger size + optimalSize = mid + textWidth = paragraph.minIntrinsicWidth + low = mid + 0.1f + } else { + // text doesn't fit, try smaller size + high = mid - 0.1f + } + } + + // final measurement at optimal size to get accurate width + val finalStyle = + style.copy( + fontSize = optimalSize.sp, + fontWeight = fontWeight, + fontStyle = fontStyle, + fontFamily = fontFamily, + ) + val finalParagraph = + androidx.compose.ui.text.Paragraph( + text = text, + style = finalStyle, + constraints = + androidx.compose.ui.unit + .Constraints(maxWidth = Int.MAX_VALUE), + density = density, + fontFamilyResolver = fontFamilyResolver, + ) + textWidth = finalParagraph.minIntrinsicWidth + + return Pair(optimalSize.sp, textWidth) +} From ba19382c62efa24c5ad6800a7f821e5e99281387 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:05:08 -0600 Subject: [PATCH 37/97] Fix custom fee rate sheet to match iOS - Restyle CustomFeeRateSheet with iOS layout (large text input, duration capsule, inline total fee + fiat display) - Add fee estimation when address not yet validated (fixes spinner never updating) - Add onUpdateFeeOptions callback to propagate custom fee options back to SendFlowManager via ChangeFeeRateOptions action - Pass walletManager to CustomFeeRateSheet for fiat conversion --- .../cove/send/CoinControlSetAmountScreen.kt | 52 ++++- .../cove/sheets/CustomFeeRateSheet.kt | 218 +++++++++++++----- .../cove/sheets/FeeRateSelectorSheet.kt | 3 + 3 files changed, 207 insertions(+), 66 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt index ce0976d34..66bf5dccf 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt @@ -34,11 +34,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.SendFlowManagerAction +import org.bitcoinppl.cove_core.StringOrData @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -64,6 +69,7 @@ fun CoinControlSetAmountScreen( totalSpendingFiat: String, utxoCount: Int, utxos: List, + app: AppManager, sendFlowManager: org.bitcoinppl.cove.SendFlowManager, walletManager: org.bitcoinppl.cove.WalletManager, presenter: org.bitcoinppl.cove.SendFlowPresenter, @@ -177,8 +183,50 @@ fun CoinControlSetAmountScreen( onDismiss = { presenter.sheetState = null }, ) } - else -> { - // other sheets (Qr, Fee) are not handled in coin control screen + is org.bitcoinppl.cove.SendFlowPresenter.SheetState.Qr -> { + ModalBottomSheet( + onDismissRequest = { presenter.sheetState = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + QrCodeScanView( + onScanned = { stringOrData -> + presenter.sheetState = null + val scannedString = + when (stringOrData) { + is StringOrData.String -> stringOrData.v1 + is StringOrData.Data -> stringOrData.v1.toString(Charsets.UTF_8) + } + sendFlowManager.enteringAddress = scannedString + }, + onDismiss = { presenter.sheetState = null }, + app = app, + ) + } + } + is org.bitcoinppl.cove.SendFlowPresenter.SheetState.Fee -> { + sendFlowManager.feeRateOptions?.let { feeOptions -> + sendFlowManager.selectedFeeRate?.let { selectedRate -> + FeeRateSelectorSheet( + app = app, + walletManager = walletManager, + sendFlowManager = sendFlowManager, + presenter = presenter, + feeOptions = feeOptions, + selectedOption = selectedRate, + onSelectFee = { newFeeOption -> + sendFlowManager.dispatch( + SendFlowManagerAction.SelectFeeRate(newFeeOption), + ) + }, + onUpdateFeeOptions = { newOptions -> + sendFlowManager.dispatch( + SendFlowManagerAction.ChangeFeeRateOptions(newOptions), + ) + }, + onDismiss = { presenter.sheetState = null }, + ) + } + } } } } 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 2cf44eba7..783b4dea6 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 @@ -2,10 +2,14 @@ package org.bitcoinppl.cove.sheets import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -20,11 +24,14 @@ import org.bitcoinppl.cove.SendFlowManager import org.bitcoinppl.cove.SendFlowPresenter import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager +import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove_core.SendFlowAlertState import org.bitcoinppl.cove_core.SendFlowException +import org.bitcoinppl.cove_core.types.Amount import org.bitcoinppl.cove_core.types.FeeRate import org.bitcoinppl.cove_core.types.FeeRateOptionWithTotalFee import org.bitcoinppl.cove_core.types.FeeRateOptionsWithTotalFee +import org.bitcoinppl.cove_core.types.FeeSpeed import java.util.Locale import kotlin.math.max import kotlin.math.round @@ -48,6 +55,7 @@ private object CustomFeeRateConstants { @Composable fun CustomFeeRateSheet( app: AppManager, + walletManager: WalletManager, sendFlowManager: SendFlowManager, presenter: SendFlowPresenter, feeOptions: FeeRateOptionsWithTotalFee, @@ -86,9 +94,19 @@ fun CustomFeeRateSheet( // get total sats with debouncing fun getTotalSatsDeduped(feeRate: Float) { - // guard against empty send flow state + // if amount is not set, we can't calculate fee if (sendFlowManager.amount == null) return - if (sendFlowManager.address == null) return + + // if address is not validated yet, estimate fee based on selected option's fee + if (sendFlowManager.address == null) { + val selectedFee = selectedOption.totalFee().asSats().toDouble() + val selectedRate = selectedOption.satPerVb().toDouble() + if (selectedRate > 0) { + val estimatedFee = (feeRate.toDouble() / selectedRate) * selectedFee + totalSats = estimatedFee.toLong() + } + return + } feeCalculationJob?.cancel() feeCalculationJob = @@ -163,6 +181,15 @@ fun CustomFeeRateSheet( } } + // calculate fiat amount + val fiatAmount = + remember(totalSats, app.prices) { + val sats = totalSats ?: return@remember "" + app.prices?.let { prices -> + "≈ ${walletManager.rust.convertAndDisplayFiat(Amount.fromSat(sats.toULong()), prices)}" + } ?: "" + } + ModalBottomSheet( onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface, @@ -174,102 +201,165 @@ fun CustomFeeRateSheet( .padding(horizontal = 16.dp) .padding(bottom = 32.dp), ) { - // title + // title (matches iOS) Text( - text = "Custom Fee Rate", + text = "Set Custom Network Fee", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 12.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) Spacer(modifier = Modifier.height(20.dp)) - // fee rate input - OutlinedTextField( - value = feeRateText, - onValueChange = { feeRateText = it }, - label = { Text("Fee Rate (sats/vB)") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Column(modifier = Modifier.fillMaxWidth()) { + // "satoshi/byte" label (matches iOS) + Text( + text = "satoshi/byte", + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = Modifier.offset(y = 4.dp), + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // slider - Slider( - value = feeRateFloat.coerceIn(1f, maxFeeRate), - onValueChange = { feeRateText = String.format(Locale.US, "%.2f", it) }, - valueRange = 1f..maxFeeRate, - modifier = Modifier.fillMaxWidth(), - ) + // fee rate input + duration capsule row (matches iOS) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = feeRateText, + onValueChange = { feeRateText = it }, + textStyle = + TextStyle( + fontSize = 34.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f), + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - // fee range display - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "1 sat/vB", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.secondary, - ) - Text( - text = "${String.format(Locale.US, "%.2f", maxFeeRate)} sat/vB", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.secondary, + // duration capsule + DurationCapsule( + speed = feeSpeed, + fontColor = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // slider + Slider( + value = feeRateFloat.coerceIn(1f, maxFeeRate), + onValueChange = { feeRateText = String.format(Locale.US, "%.2f", it) }, + valueRange = 1f..maxFeeRate, + modifier = Modifier.fillMaxWidth(), ) - } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(4.dp)) - // total fee display - totalSats?.let { sats -> - Card( + // total fee + fiat display (matches iOS) + Row( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { + if (totalSats == null) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { Text( - text = "Total Network Fee", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.secondary, + text = "$totalSats sats", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = "$sats sats", - fontWeight = FontWeight.Bold, - fontSize = 18.sp, + text = fiatAmount, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Spacer(modifier = Modifier.weight(1f)) } } Spacer(modifier = Modifier.height(20.dp)) - // done button + HorizontalDivider() + + Spacer(modifier = Modifier.height(20.dp)) + + // done button (matches iOS styling) Button( onClick = onDismiss, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 28.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = CoveColor.midnightBlue, + ), + shape = RoundedCornerShape(10.dp), ) { - Text("Done") + Text( + text = "Done", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 8.dp), + ) } + + Spacer(modifier = Modifier.height(14.dp)) } } } + +@Composable +private fun DurationCapsule( + speed: FeeSpeed, + fontColor: Color, +) { + val durationText = + remember(speed) { + when (speed) { + is FeeSpeed.Fast -> "~10 min" + is FeeSpeed.Medium -> "~30 min" + is FeeSpeed.Slow -> "~1 hour" + is FeeSpeed.Custom -> { + val mins = speed.durationMins.toInt() + when { + mins < 60 -> "~$mins min" + mins < 120 -> "~1 hour" + else -> "~${mins / 60} hours" + } + } + } + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Gray.copy(alpha = 0.2f), + ) { + Text( + text = durationText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = fontColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} 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 90dd3695d..617b945f7 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 @@ -39,6 +39,7 @@ fun FeeRateSelectorSheet( feeOptions: FeeRateOptionsWithTotalFee, selectedOption: FeeRateOptionWithTotalFee, onSelectFee: (FeeRateOptionWithTotalFee) -> Unit, + onUpdateFeeOptions: (FeeRateOptionsWithTotalFee) -> Unit, onDismiss: () -> Unit, ) { var showCustomFeeSheet by remember { mutableStateOf(false) } @@ -153,12 +154,14 @@ fun FeeRateSelectorSheet( if (showCustomFeeSheet) { CustomFeeRateSheet( app = app, + walletManager = walletManager, sendFlowManager = sendFlowManager, presenter = presenter, feeOptions = currentFeeOptions, selectedOption = selectedOption, onUpdateFeeOptions = { newOptions, newSelected -> currentFeeOptions = newOptions + onUpdateFeeOptions(newOptions) onSelectFee(newSelected) showCustomFeeSheet = false }, From 4f9c93824ec75239ca23fe5a6b1ce2c8e28b1e34 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:10:51 -0600 Subject: [PATCH 38/97] Fix custom fee option not showing after Done pressed Create estimated FeeRateOptionWithTotalFee when address is null so the custom option is available in the fee selector after dismissing the custom fee sheet. --- .../org/bitcoinppl/cove/sheets/CustomFeeRateSheet.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 783b4dea6..52b9498e9 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 @@ -104,6 +104,15 @@ fun CustomFeeRateSheet( 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) } return } From 6c34c060de7f0f0cf4818d9f2a92fba12900664a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:26:29 -0600 Subject: [PATCH 39/97] Show clear button only when field is focused Add focus state tracking to AutoSizeTextField and AddressWidget so the clear button only appears when the user is actively editing, reducing visual clutter. --- .../org/bitcoinppl/cove/send/SendScreen.kt | 31 +++++++++++++++++-- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 9 +++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index aa3a7e2d0..2ad869bce 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -286,6 +287,7 @@ private fun AmountWidget( var amount by remember { mutableStateOf(initialAmount) } var showUnitMenu by remember { mutableStateOf(false) } var textWidth by remember { mutableStateOf(0.dp) } + var isFocused by remember { mutableStateOf(false) } // offset to compensate for unit dropdown (matches iOS) val configuration = LocalConfiguration.current @@ -348,10 +350,12 @@ private fun AmountWidget( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().offset(x = amountOffset), onTextWidthChanged = { width -> textWidth = width }, + onFocusChanged = { focused -> isFocused = focused }, ) // clear button: positioned at top, horizontally follows the text's right edge // text is centered with an offset, so X position = offset + half text width + small margin - if (amount.isNotEmpty()) { + // only show when focused and has content + if (isFocused && amount.isNotEmpty()) { IconButton( onClick = { amount = "" @@ -466,6 +470,7 @@ private fun AddressWidget( onAddressChanged: (String) -> Unit, ) { var address by remember { mutableStateOf(initialAddress) } + var isFocused by remember { mutableStateOf(false) } // bidirectional sync: update local state when parent state changes androidx.compose.runtime.LaunchedEffect(initialAddress) { @@ -491,6 +496,23 @@ private fun AddressWidget( fontSize = 14.sp, ) } + // clear button - only visible when focused and has content + if (isFocused && address.isNotEmpty()) { + IconButton( + onClick = { + address = "" + onAddressChanged("") + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear address", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } IconButton( onClick = onScanQr, modifier = Modifier.offset(x = 8.dp), @@ -512,7 +534,12 @@ private fun AddressWidget( lineHeight = 20.sp, fontWeight = FontWeight.Medium, ), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + }, ) Spacer(Modifier.height(24.dp)) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 305949c85..5e2adbb64 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalDensity @@ -214,6 +215,7 @@ fun AutoSizeTextField( fontWeight: FontWeight? = null, textAlign: TextAlign? = null, onTextWidthChanged: ((androidx.compose.ui.unit.Dp) -> Unit)? = null, + onFocusChanged: ((Boolean) -> Unit)? = null, ) { val density = LocalDensity.current val fontFamilyResolver = LocalFontFamilyResolver.current @@ -284,7 +286,12 @@ fun AutoSizeTextField( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), cursorBrush = SolidColor(color), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + onFocusChanged?.invoke(focusState.isFocused) + }, ) } } From 0f3551413a9b0325aa1e382b26377f92913034ee Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:26:39 -0600 Subject: [PATCH 40/97] Add isShowingAlert property for alert dismissal handling Make disappearing state observable and add isShowingAlert computed property that respects the dismissal animation flag, preventing alerts from reappearing during dismissal. --- .../java/org/bitcoinppl/cove/SendFlowPresenter.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowPresenter.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowPresenter.kt index b906a6efa..8155678ab 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowPresenter.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowPresenter.kt @@ -14,17 +14,13 @@ import org.bitcoinppl.cove_core.types.* import java.io.Closeable import java.util.concurrent.atomic.AtomicBoolean -/** - * send flow presenter - manages UI state for send flow screens - * ported from iOS SendFlowPresenter.swift - */ +/** send flow presenter - manages UI state for send flow screens */ class SendFlowPresenter( val app: AppManager, val manager: WalletManager, ) : Closeable { - // TODO: use when implementing alert dialogs - prevents alerts from reappearing during dismissal animations - // see iOS showingAlert at SendFlowPresenter.swift:38 for usage pattern - private var disappearing: Boolean = false + // prevents alerts from reappearing during dismissal animations + private var disappearing by mutableStateOf(false) private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val isClosed = AtomicBoolean(false) @@ -33,6 +29,9 @@ class SendFlowPresenter( var sheetState by mutableStateOf?>(null) var alertState by mutableStateOf?>(null) + val isShowingAlert: Boolean + get() = alertState != null && !disappearing + var lastWorkingFeeRate by mutableStateOf(null) var erroredFeeRate by mutableStateOf(null) From e4162c6a51a207e9dee4a35f3393e5d44874a9dc Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:27:23 -0600 Subject: [PATCH 41/97] Add colored dots to fee duration capsules and refine selector styling - Add colored circle indicator to DurationCapsule using feeSpeedToCircleColor - Add subtle border on unselected fee option cards - Remove explicit checkmark (selection shown via background/border styling) - Use 8dp corner radius on capsules for consistency --- .../cove/sheets/CustomFeeRateSheet.kt | 46 +++++++++++------ .../cove/sheets/FeeRateSelectorSheet.kt | 50 +++++++++---------- 2 files changed, 56 insertions(+), 40 deletions(-) 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 52b9498e9..130250ee2 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 @@ -1,6 +1,8 @@ package org.bitcoinppl.cove.sheets +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions @@ -8,6 +10,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -25,6 +28,7 @@ import org.bitcoinppl.cove.SendFlowPresenter import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.utils.toColor import org.bitcoinppl.cove_core.SendFlowAlertState import org.bitcoinppl.cove_core.SendFlowException import org.bitcoinppl.cove_core.types.Amount @@ -32,6 +36,7 @@ import org.bitcoinppl.cove_core.types.FeeRate 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 import kotlin.math.max import kotlin.math.round @@ -47,10 +52,7 @@ private object CustomFeeRateConstants { const val ALERT_DELAY_MS = 850L } -/** - * custom fee rate sheet - allows user to set custom sats/vbyte with slider - * ported from iOS SendFlowCustomFeeRateView.swift - */ +/** custom fee rate sheet - allows user to set custom sats/vbyte with slider */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomFeeRateSheet( @@ -210,7 +212,7 @@ fun CustomFeeRateSheet( .padding(horizontal = 16.dp) .padding(bottom = 32.dp), ) { - // title (matches iOS) + // title Text( text = "Set Custom Network Fee", style = MaterialTheme.typography.titleMedium, @@ -225,7 +227,7 @@ fun CustomFeeRateSheet( Spacer(modifier = Modifier.height(20.dp)) Column(modifier = Modifier.fillMaxWidth()) { - // "satoshi/byte" label (matches iOS) + // "satoshi/byte" label Text( text = "satoshi/byte", fontWeight = FontWeight.Medium, @@ -236,7 +238,7 @@ fun CustomFeeRateSheet( Spacer(modifier = Modifier.height(8.dp)) - // fee rate input + duration capsule row (matches iOS) + // fee rate input + duration capsule row Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -276,7 +278,7 @@ fun CustomFeeRateSheet( Spacer(modifier = Modifier.height(4.dp)) - // total fee + fiat display (matches iOS) + // total fee + fiat display Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -311,7 +313,7 @@ fun CustomFeeRateSheet( Spacer(modifier = Modifier.height(20.dp)) - // done button (matches iOS styling) + // done button Button( onClick = onDismiss, modifier = @@ -359,16 +361,30 @@ private fun DurationCapsule( } } + val circleColor = feeSpeedToCircleColor(speed).toColor() + Surface( shape = RoundedCornerShape(8.dp), color = Color.Gray.copy(alpha = 0.2f), ) { - Text( - text = durationText, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = fontColor, + Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(circleColor), + ) + Text( + text = durationText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = fontColor, + ) + } } } 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 617b945f7..ec78e7225 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 @@ -4,9 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -20,15 +19,14 @@ import org.bitcoinppl.cove.SendFlowManager import org.bitcoinppl.cove.SendFlowPresenter import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.utils.toColor 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 - * ported from iOS SendFlowSelectFeeRateView.swift - */ +/** fee rate selector sheet - displays fast/medium/slow fee options */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeeRateSelectorSheet( @@ -198,7 +196,7 @@ private fun FeeOptionCard( if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.75f) } else { - Color.Transparent + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) } Box( @@ -276,17 +274,6 @@ private fun FeeOptionCard( color = contentColor, ) } - - // checkmark if selected - if (isSelected) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = contentColor, - modifier = Modifier.size(20.dp), - ) - } } } } @@ -296,7 +283,6 @@ private fun DurationCapsule( speed: FeeSpeed, fontColor: Color, ) { - // get duration string from FeeSpeed val durationText = remember(speed) { when (speed) { @@ -314,15 +300,29 @@ private fun DurationCapsule( } } + val circleColor = feeSpeedToCircleColor(speed).toColor() + Surface( - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(8.dp), color = fontColor.copy(alpha = 0.2f), ) { - Text( - text = durationText, - style = MaterialTheme.typography.labelSmall, - color = fontColor, + Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(circleColor), + ) + Text( + text = durationText, + style = MaterialTheme.typography.labelSmall, + color = fontColor, + ) + } } } From 39f59eb9ca3417fafe323038c7f00313125fb732 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:27:38 -0600 Subject: [PATCH 42/97] Clean up redundant iOS reference comments in SendFlowContainer --- .../org/bitcoinppl/cove/SendFlowContainer.kt | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index cb72cdd4b..81b61d495 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -26,10 +26,7 @@ import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* -/** - * UI state for tracking send transaction progress - * Mirrors iOS SendState enum - */ +/** UI state for tracking send transaction progress */ sealed interface SendState { data object Idle : SendState @@ -42,10 +39,7 @@ sealed interface SendState { ) : SendState } -/** - * send flow container - manages WalletManager + SendFlowManager lifecycle - * ported from iOS SendFlowContainer.swift - */ +/** send flow container - manages WalletManager + SendFlowManager lifecycle */ @Composable fun SendFlowContainer( app: AppManager, @@ -120,7 +114,7 @@ fun SendFlowContainer( } } - // observe unit changes and notify send flow manager (mirrors iOS .onChange) + // observe unit changes and notify send flow manager var previousUnit by remember { mutableStateOf(wm.walletMetadata?.selectedUnit) } LaunchedEffect(wm.walletMetadata?.selectedUnit) { val currentUnit = wm.walletMetadata?.selectedUnit @@ -131,7 +125,7 @@ fun SendFlowContainer( previousUnit = currentUnit } - // observe fiatOrBtc changes and notify send flow manager (mirrors iOS .onChange) + // observe fiatOrBtc changes and notify send flow manager var previousFiatOrBtc by remember { mutableStateOf(wm.walletMetadata?.fiatOrBtc) } LaunchedEffect(wm.walletMetadata?.fiatOrBtc) { val currentFiatOrBtc = wm.walletMetadata?.fiatOrBtc @@ -142,14 +136,14 @@ fun SendFlowContainer( previousFiatOrBtc = currentFiatOrBtc } - // observe app prices changes and notify send flow manager (mirrors iOS .onChange) + // observe app prices changes and notify send flow manager LaunchedEffect(app.prices) { app.prices?.let { prices -> sfm.dispatch(SendFlowManagerAction.NotifyPricesChanged(prices)) } } - // observe focus field changes and notify send flow manager (mirrors iOS .onChange) + // observe focus field changes and notify send flow manager var previousFocusField by remember { mutableStateOf(presenter.focusField) } LaunchedEffect(presenter.focusField) { val currentFocusField = presenter.focusField @@ -160,7 +154,7 @@ fun SendFlowContainer( previousFocusField = currentFocusField } - // observe auth lock state changes (mirrors iOS .onChange for auth.lockState) + // observe auth lock state changes LaunchedEffect(Auth.isLocked) { if (!Auth.isLocked) { // after unlock, validate and focus appropriate field @@ -419,7 +413,7 @@ private fun SendFlowRouteToScreen( var showErrorAlert by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - // lock on appear for hot wallets (mirrors iOS .onAppear) + // lock on appear for hot wallets LaunchedEffect(Unit) { kotlinx.coroutines.delay(50) if (walletManager.walletMetadata?.walletType == WalletType.HOT) { @@ -427,7 +421,7 @@ private fun SendFlowRouteToScreen( } } - // timed unlock on disappear (mirrors iOS .onDisappear) + // timed unlock on disappear DisposableEffect(Unit) { onDispose { val lockedAt = Auth.lockedAt ?: return@onDispose From 4b126cdcc9caa5c32f8f173de941e5a413d4f0d1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:44:58 -0600 Subject: [PATCH 43/97] Update HardwareExportScreen to match iOS styling - Add midnight blue header with balance display and visibility toggle - Center amount display with offset to account for unit label - Replace wallet color indicator with BitcoinShieldIcon - Use AutoSizeText for Export/Import buttons - Improve address and detail row spacing - Add bitcoinOrange color to CoveColor - Add alignWithText parameter to BitcoinShieldIcon (default true) to automatically apply -5dp vertical offset for text alignment --- .../bitcoinppl/cove/HardwareExportScreen.kt | 391 ++++++++++++------ .../org/bitcoinppl/cove/ui/theme/Color.kt | 1 + .../cove/views/BitcoinShieldIcon.kt | 17 +- .../WalletTransactionsScreen.kt | 6 +- 4 files changed, 284 insertions(+), 131 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt index f81893f69..78718652e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -14,9 +15,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +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.rememberScrollState @@ -31,6 +34,8 @@ import androidx.compose.material.icons.filled.Output import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -58,7 +63,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -69,6 +78,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.midnightBtn +import org.bitcoinppl.cove.ui.theme.title3 +import org.bitcoinppl.cove.views.AutoSizeText +import org.bitcoinppl.cove.views.BitcoinShieldIcon import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* import java.io.File @@ -197,12 +209,14 @@ fun HardwareExportScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = Color.Transparent, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White, ), title = { }, actions = { @@ -220,126 +234,164 @@ fun HardwareExportScreen( Icon( Icons.Default.Delete, contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error, + tint = Color.White, ) } }, ) }, ) { paddingValues -> - Column( + Box( modifier = modifier .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + .padding(paddingValues), ) { - // header section - Column { - Text( - text = "You're sending", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 6.dp), - ) + // background pattern + Image( + painter = painterResource(id = R.drawable.image_chain_code_pattern_horizontal), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxHeight() + .align(Alignment.TopCenter) + .offset(y = (-40).dp) + .graphicsLayer(alpha = 0.25f), + ) - Text( - text = "The amount they will receive", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, + Column(modifier = Modifier.fillMaxSize()) { + val configuration = LocalConfiguration.current + val screenHeightDp = configuration.screenHeightDp.dp + val headerHeight = screenHeightDp * 0.145f + + // balance header + BalanceHeader( + walletManager = walletManager, + height = headerHeight, ) - } - // amount display - Column( - modifier = Modifier.padding(top = 8.dp), - horizontalAlignment = Alignment.Start, - ) { - Row( - verticalAlignment = Alignment.Bottom, + // main content + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text( - text = walletManager.amountFmt(details.sendingAmount()), - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - ) + // header section + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = "You're sending", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 6.dp), + ) - Text( - text = if (metadata?.selectedUnit?.name == "SAT") "sats" else "btc", + Text( + text = "The amount they will receive", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + fontWeight = FontWeight.Medium, + ) + } + + // amount display - centered with offset to account for unit label + Column( modifier = Modifier - .padding(start = 8.dp, bottom = 10.dp), - ) - } + .fillMaxWidth() + .padding(top = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.offset(x = 32.dp), + ) { + Text( + text = walletManager.amountFmt(details.sendingAmount()), + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + ) - Text( - text = fiatAmount, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - ) - } + Text( + text = if (metadata?.selectedUnit?.name == "SAT") "sats" else "btc", + modifier = + Modifier + .padding(start = 8.dp, bottom = 10.dp), + ) + } - // account section - AccountSection(metadata) + Text( + text = fiatAmount, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } - HorizontalDivider() + // account section + AccountSection(metadata) - // address section - AddressSection( - address = details.sendingTo().spacedOut(), - onCopy = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("address", details.sendingTo().unformatted()) - clipboard.setPrimaryClip(clip) - }, - onClick = { sheetState = SheetState.InputOutputDetails }, - ) + HorizontalDivider() - HorizontalDivider() - - // sign transaction section - when (val hwMetadata = metadata?.hardwareMetadata) { - is HardwareWalletMetadata.TapSigner -> { - SignTapSignerSection( - tapSigner = hwMetadata.v1, - onSign = { - val route = - TapSignerRoute.EnterPin( - tapSigner = hwMetadata.v1, - action = AfterPinAction.Sign(details.psbt()), - ) - app.sheetState = TaggedItem(AppSheetState.TapSigner(route)) + // address section + AddressSection( + address = details.sendingTo().spacedOut(), + onCopy = { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("address", details.sendingTo().unformatted()) + clipboard.setPrimaryClip(clip) }, + onClick = { sheetState = SheetState.InputOutputDetails }, ) - } - else -> { - SignTransactionSection( - onExport = { confirmationState = ConfirmationState.ExportTxn }, - onImport = { confirmationState = ConfirmationState.ImportSignature }, - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + + // sign transaction section + when (val hwMetadata = metadata?.hardwareMetadata) { + is HardwareWalletMetadata.TapSigner -> { + SignTapSignerSection( + tapSigner = hwMetadata.v1, + onSign = { + val route = + TapSignerRoute.EnterPin( + tapSigner = hwMetadata.v1, + action = AfterPinAction.Sign(details.psbt()), + ) + app.sheetState = TaggedItem(AppSheetState.TapSigner(route)) + }, + ) + } + else -> { + SignTransactionSection( + onExport = { confirmationState = ConfirmationState.ExportTxn }, + onImport = { confirmationState = ConfirmationState.ImportSignature }, + ) + } + } - // more details button - TextButton( - onClick = { sheetState = SheetState.Details }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "More details", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, - ) - } + Spacer(modifier = Modifier.height(16.dp)) + + // more details button + TextButton( + onClick = { sheetState = SheetState.Details }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "More details", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + fontWeight = FontWeight.Medium, + ) + } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(32.dp)) + } + } } } @@ -587,24 +639,91 @@ fun HardwareExportScreen( } } +@Composable +private fun BalanceHeader( + walletManager: WalletManager, + height: androidx.compose.ui.unit.Dp, +) { + val metadata = walletManager.walletMetadata + val balance = walletManager.balance.spendable() + val isHidden = metadata?.sensitiveVisible != true + + val balanceString = + if (isHidden) { + "••••••" + } else { + when (metadata?.selectedUnit) { + BitcoinUnit.BTC -> balance.btcString() + else -> balance.satsString() + } + } + + val denomination = + when (metadata?.selectedUnit) { + BitcoinUnit.BTC -> "btc" + else -> "sats" + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 16.dp), + ) { + Row( + modifier = + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Balance", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + ) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = balanceString, + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.size(6.dp)) + Text( + text = denomination, + color = Color.White, + fontSize = 14.sp, + modifier = Modifier.offset(y = (-4).dp), + ) + } + } + IconButton( + onClick = { walletManager.dispatch(WalletManagerAction.ToggleSensitiveVisibility) }, + modifier = Modifier.offset(y = 8.dp, x = 8.dp), + ) { + Icon( + imageVector = if (isHidden) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = null, + tint = Color.White, + ) + } + } + } +} + @Composable private fun AccountSection(metadata: WalletMetadata?) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // bitcoin shield icon placeholder - Box( - modifier = - Modifier - .size(24.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp), - ), - ) + BitcoinShieldIcon(size = 24.dp, color = CoveColor.bitcoinOrange) - Column { + Column(modifier = Modifier.padding(start = 4.dp)) { metadata?.masterFingerprint?.let { fingerprint -> Text( text = fingerprint.asUppercase(), @@ -637,16 +756,16 @@ private fun AddressSection( .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "Address", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - modifier = Modifier.weight(0.3f), ) + Spacer(Modifier.weight(1f)) + Text( text = address, style = MaterialTheme.typography.bodySmall, @@ -654,8 +773,10 @@ private fun AddressSection( textAlign = TextAlign.End, modifier = Modifier - .weight(0.7f) + .weight(3f) + .padding(start = 24.dp) .clickable(onClick = onCopy), + maxLines = 4, ) } } @@ -665,7 +786,7 @@ private fun SignTransactionSection( onExport: () -> Unit, onImport: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(17.dp)) { Text( text = "Sign Transaction", style = MaterialTheme.typography.bodySmall, @@ -675,7 +796,7 @@ private fun SignTransactionSection( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Button( onClick = onExport, @@ -685,16 +806,24 @@ private fun SignTransactionSection( containerColor = CoveColor.btnPrimary, contentColor = CoveColor.midnightBlue, ), + shape = RoundedCornerShape(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 12.dp, + ), ) { Icon( Icons.Default.Output, contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), ) - Text( - "Export Transaction", - modifier = Modifier.padding(start = 4.dp), - fontSize = 12.sp, + AutoSizeText( + text = "Export Transaction", + modifier = Modifier.padding(start = 6.dp), + maxFontSize = 12.sp, + minimumScaleFactor = 0.75f, + fontWeight = FontWeight.Medium, + color = CoveColor.midnightBlue, ) } @@ -706,16 +835,24 @@ private fun SignTransactionSection( containerColor = CoveColor.btnPrimary, contentColor = CoveColor.midnightBlue, ), + shape = RoundedCornerShape(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 12.dp, + ), ) { Icon( Icons.AutoMirrored.Filled.Input, contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), ) - Text( - "Import Signature", - modifier = Modifier.padding(start = 4.dp), - fontSize = 12.sp, + AutoSizeText( + text = "Import Signature", + modifier = Modifier.padding(start = 6.dp), + maxFontSize = 12.sp, + minimumScaleFactor = 0.75f, + fontWeight = FontWeight.Medium, + color = CoveColor.midnightBlue, ) } } @@ -783,14 +920,13 @@ private fun TransactionDetailsSheet( HorizontalDivider() // details section - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { // address row (tappable) Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onShowInputOutput), - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "Address", @@ -798,16 +934,19 @@ private fun TransactionDetailsSheet( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) + Spacer(Modifier.weight(1f)) Text( text = details.sendingTo().spacedOut(), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.End, - modifier = Modifier.weight(1f).padding(start = 16.dp), + modifier = Modifier.weight(3f).padding(start = 24.dp), maxLines = 3, ) } + Spacer(modifier = Modifier.height(4.dp)) + // network fee row (with warning styling if >20%) Row( modifier = Modifier.fillMaxWidth(), @@ -832,6 +971,8 @@ private fun TransactionDetailsSheet( ) } + Spacer(modifier = Modifier.height(4.dp)) + // they'll receive row Row( modifier = Modifier.fillMaxWidth(), @@ -905,7 +1046,7 @@ private fun InputOutputDetailsSheet( ) { Text( text = "Inputs & Outputs", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt index 917a0fa81..bb233ad52 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Color.kt @@ -16,6 +16,7 @@ object CoveColor { val btnPrimary = ComposeColor(0xFFE5EAEF) val coveLightGray = ComposeColor(0xFFE5EAEF) val duskBlue = ComposeColor(0xFF3A4254) + val bitcoinOrange = ComposeColor(0xFFFF9500) // system green colors (theme-aware) val SystemGreenLight = ComposeColor(0xFF34C759) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt index 8f0a72a65..2c065d801 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/BitcoinShieldIcon.kt @@ -1,6 +1,7 @@ package org.bitcoinppl.cove.views import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -12,13 +13,27 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +/** + * Bitcoin shield icon with optional text alignment adjustment. + * + * @param alignWithText When true (default), applies a -5.dp vertical offset to visually + * align with adjacent text. Set to false when used in contexts where this adjustment + * is not needed. + */ @Composable fun BitcoinShieldIcon( modifier: Modifier = Modifier, size: Dp = 13.dp, color: Color = Color.White, + alignWithText: Boolean = true, ) { - Canvas(modifier = modifier.size(size)) { + val adjustedModifier = + if (alignWithText) { + modifier.offset(y = (-5).dp) + } else { + modifier + } + Canvas(modifier = adjustedModifier.size(size)) { val scaleFactor = size.toPx() / 125f scale(scaleFactor, scaleFactor) { drawShield(color) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index c6d77c82d..8c0af6c7d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -195,11 +195,7 @@ fun WalletTransactionsScreen( verticalAlignment = Alignment.CenterVertically, ) { if (isColdWallet) { - BitcoinShieldIcon( - modifier = Modifier.offset(y = (-5).dp), - size = 13.dp, - color = Color.White, - ) + BitcoinShieldIcon(size = 13.dp, color = Color.White) Spacer(modifier = Modifier.size(8.dp)) } AutoSizeText( From 8c911512dfaa3fa9f4f03ca6ae95b933521368f2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:53:46 -0600 Subject: [PATCH 44/97] Add Typography.title3 extension and fix Android/iOS font parity Add title3 extension (20sp) to match iOS .title3 for sheet/modal headers. Update ReceiveAddressSheet QR code styling to match iOS: - Remove excessive inner padding - Use title3 for wallet name - Increase name-to-QR spacing to 24dp Update all sheet/modal headers from titleLarge (22sp) to title3 (20sp): - HotWalletSelectScreen, HotWalletImportScreen - NewWalletSelectScreen, NfcScanSheet, NfcLabelImportSheet - WalletImportHelpSheet --- .../flow/new_wallet/NewWalletSelectScreen.kt | 3 ++- .../cold_wallet/WalletImportHelpSheet.kt | 3 ++- .../hot_wallet/HotWalletImportScreen.kt | 7 ++++--- .../hot_wallet/HotWalletSelectScreen.kt | 3 ++- .../bitcoinppl/cove/nfc/NfcLabelImportSheet.kt | 5 +++-- .../org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 5 +++-- .../java/org/bitcoinppl/cove/ui/theme/Type.kt | 17 +++++++++++++++-- .../wallet_transactions/ReceiveAddressSheet.kt | 8 ++++---- 8 files changed, 35 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt index 34b7246ae..fa11d85e9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt @@ -59,6 +59,7 @@ import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.Wallet @@ -367,7 +368,7 @@ fun NewWalletSelectScreen( ) { Text( text = "Import Hardware Wallet", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, modifier = Modifier.padding(bottom = 16.dp), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/WalletImportHelpSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/WalletImportHelpSheet.kt index 49d511dee..8a5734322 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/WalletImportHelpSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/WalletImportHelpSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.bitcoinppl.cove.ui.theme.title3 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -32,7 +33,7 @@ fun WalletImportHelpSheet(onDismiss: () -> Unit) { ) { Text( text = "How do I get my wallet export QR code?", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index c25ef2574..6e4492c26 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -94,6 +94,7 @@ import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.ImportWalletManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.* @@ -879,7 +880,7 @@ private fun QrScannerContent( Text( text = "Scan Seed Phrase QR Code", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.SemiBold, color = Color.White, ) @@ -1127,7 +1128,7 @@ private fun NfcScannerSheet( Text( text = "Ready to Scan", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, color = Color.White, ) @@ -1149,7 +1150,7 @@ private fun NfcScannerSheet( Text( text = "NFC Unavailable", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, color = Color.White, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt index afc777cae..bfa21362f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.utils.intoRoute import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.ImageButton @@ -223,7 +224,7 @@ fun HotWalletSelectScreen( ) { Text( text = stringResource(R.string.label_select_number_of_words), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp), ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt index 6bae0ba7b..b62db2ead 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.collect import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove_core.LabelManager @OptIn(ExperimentalMaterial3Api::class) @@ -141,7 +142,7 @@ fun NfcLabelImportSheet( Text( text = "Ready to Scan", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, color = Color.White, ) @@ -163,7 +164,7 @@ fun NfcLabelImportSheet( Text( text = "NFC Unavailable", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.Bold, color = Color.White, ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt index ccb183285..e02db6f4c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.bitcoinppl.cove.AppAlertState +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove_core.multiFormatTryFromNfcMessage @@ -125,7 +126,7 @@ fun NfcScanSheet( if (errorMessage != null) { Text( text = "Error", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -149,7 +150,7 @@ fun NfcScanSheet( Spacer(modifier = Modifier.height(24.dp)) Text( text = "Ready to Scan", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, ) Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt index 68f51ead7..bbcc48f8e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Type.kt @@ -9,8 +9,8 @@ import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.sp // tight text styling to match iOS spacing behavior -private val tightPlatformStyle = PlatformTextStyle(includeFontPadding = false) -private val tightLineHeightStyle = +internal val tightPlatformStyle = PlatformTextStyle(includeFontPadding = false) +internal val tightLineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.Both, @@ -175,3 +175,16 @@ val Typography = lineHeightStyle = tightLineHeightStyle, ), ) + +// extension to match iOS title3 (20pt) +val Typography.title3: TextStyle + get() = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + platformStyle = tightPlatformStyle, + lineHeightStyle = tightLineHeightStyle, + ) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt index a2273a37b..08442e881 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt @@ -48,6 +48,7 @@ import org.bitcoinppl.cove.QrCodeGenerator import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.isLight +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove_core.types.AddressInfoWithDerivation @OptIn(ExperimentalMaterial3Api::class) @@ -182,13 +183,13 @@ private fun ReceiveAddressSheetContent( // wallet name header Text( text = walletName, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.title3, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) // QR code Box( @@ -198,8 +199,7 @@ private fun ReceiveAddressSheetContent( .padding(horizontal = 32.dp) .aspectRatio(1f) .clip(RoundedCornerShape(12.dp)) - .background(Color.White) - .padding(18.dp), + .background(Color.White), contentAlignment = Alignment.Center, ) { when { From 626aa6d921003ffe404347ce91ef68871632ea9d Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:57:24 -0600 Subject: [PATCH 45/97] Control QR code safe buffer entirely using padding --- .../app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt | 5 ++++- .../cove/wallet_transactions/ReceiveAddressSheet.kt | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt index 9419be81d..5157a4cff 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt @@ -20,7 +20,10 @@ object QrCodeGenerator { size: Int = 512, errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.L, ): Bitmap { - val hints = mapOf(EncodeHintType.ERROR_CORRECTION to errorCorrectionLevel) + val hints = mapOf( + EncodeHintType.ERROR_CORRECTION to errorCorrectionLevel, + EncodeHintType.MARGIN to 0, + ) val writer = QRCodeWriter() val bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt index 08442e881..ce3a73c39 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt @@ -199,7 +199,8 @@ private fun ReceiveAddressSheetContent( .padding(horizontal = 32.dp) .aspectRatio(1f) .clip(RoundedCornerShape(12.dp)) - .background(Color.White), + .background(Color.White) + .padding(12.dp), contentAlignment = Alignment.Center, ) { when { From 0f6032792e601c6eeaac19adfccdd16128eb68c3 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:57:41 -0600 Subject: [PATCH 46/97] Set consistent font weight for transaction date --- .../cove/transaction_details/TransactionDetailsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index a9fa2ee57..b8dd06f19 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -559,7 +559,7 @@ fun TransactionDetailsScreen( text = formattedDate, color = sub, fontSize = 16.sp, - fontWeight = if (isReceived) FontWeight.SemiBold else FontWeight.Normal, + fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, lineHeight = 22.sp, ) From 72f31395e7127378cfb2d7b581ba7c9b4df96259 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:57:50 -0600 Subject: [PATCH 47/97] Set consistent font weight for transaction date --- .../TransactionDetails/TransactionDetailsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsView.swift b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsView.swift index 851b71f46..bbe7caa76 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsView.swift @@ -152,6 +152,7 @@ struct TransactionDetailsView: View { .foregroundColor(.gray) Text(transactionDetails.confirmationDateTime() ?? "Unknown") + .fontWeight(.semibold) .foregroundColor(.gray) } .multilineTextAlignment(.center) From 0e59c0a2b8aa94fb1c2471de5dccbacb2173ca77 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:58:03 -0600 Subject: [PATCH 48/97] Update chain code pattern horizontal SVG --- .../main/res/drawable/image_chain_code_pattern_horizontal.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/res/drawable/image_chain_code_pattern_horizontal.xml b/android/app/src/main/res/drawable/image_chain_code_pattern_horizontal.xml index 9c559e409..9349bbda3 100644 --- a/android/app/src/main/res/drawable/image_chain_code_pattern_horizontal.xml +++ b/android/app/src/main/res/drawable/image_chain_code_pattern_horizontal.xml @@ -6,7 +6,7 @@ From d9bd48f4f0e3ecddbd740be484d4fd88d240304f Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 10:58:54 -0600 Subject: [PATCH 49/97] Add validation alert dialog to send flow --- .../org/bitcoinppl/cove/SendFlowContainer.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index 81b61d495..ca3c14f7f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -541,4 +541,26 @@ private fun SendFlowRouteToScreen( ) } } + + // validation alert dialog (shown across all send routes) + if (presenter.isShowingAlert) { + AlertDialog( + onDismissRequest = { + presenter.setDisappearing() + presenter.alertState = null + }, + title = { Text(presenter.alertTitle()) }, + text = { Text(presenter.alertMessage()) }, + confirmButton = { + TextButton( + onClick = { + presenter.setDisappearing() + presenter.alertButtonAction()?.invoke() + }, + ) { + Text("OK") + } + }, + ) + } } From 618d93f69fd75df870bd65e060c8293b63db800f Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 11:39:48 -0600 Subject: [PATCH 50/97] Add unsigned transactions widget to wallet screen Display unsigned transactions at top of transaction list with lock icon, warning indicator, and 'Pending Signature' status. Long-press to delete, tap to navigate to hardware export screen. --- .../WalletTransactionsScreen.kt | 183 ++++++++++++++++-- android/app/src/main/res/values/strings.xml | 2 + 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 8c0af6c7d..c3534a67e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -17,11 +17,13 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.NorthEast @@ -55,6 +57,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -77,8 +80,10 @@ import org.bitcoinppl.cove_core.FiatOrBtc import org.bitcoinppl.cove_core.HotWalletRoute import org.bitcoinppl.cove_core.NewWalletRoute import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.RouteFactory import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.Transaction +import org.bitcoinppl.cove_core.UnsignedTransaction import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType @@ -155,6 +160,7 @@ fun WalletTransactionsScreen( is WalletLoadState.SCANNING -> state.txns else -> emptyList() } + val unsignedTransactions = manager?.unsignedTransactions ?: emptyList() // use Material Design system colors for native Android feel val listBg = MaterialTheme.colorScheme.background @@ -247,17 +253,25 @@ fun WalletTransactionsScreen( } }, actions = { - IconButton(onClick = onQrCode) { - Icon( - imageVector = Icons.Filled.QrCode2, - contentDescription = "QR Code", - ) - } - IconButton(onClick = onMore) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = "More", - ) + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + IconButton( + onClick = onQrCode, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Filled.QrCode2, + contentDescription = "QR Code", + ) + } + IconButton( + onClick = onMore, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "More", + ) + } } }, ) @@ -368,7 +382,7 @@ fun WalletTransactionsScreen( ) // render real transactions or show empty state - if (transactions.isEmpty()) { + if (transactions.isEmpty() && unsignedTransactions.isEmpty()) { // empty state Box( modifier = @@ -389,6 +403,21 @@ fun WalletTransactionsScreen( state = listState, modifier = Modifier.weight(1f), ) { + // render unsigned transactions first (pending signature) + items( + items = unsignedTransactions, + key = { it.id().toString() }, + ) { unsignedTxn -> + UnsignedTransactionWidget( + txn = unsignedTxn, + primaryText = primaryText, + secondaryText = secondaryText, + app = app, + manager = manager, + ) + HorizontalDivider(color = dividerColor, thickness = 0.5.dp) + } + itemsIndexed(transactions) { index, txn -> when (txn) { is Transaction.Confirmed -> { @@ -586,6 +615,136 @@ private fun TransactionWidget( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun UnsignedTransactionWidget( + txn: UnsignedTransaction, + primaryText: Color, + secondaryText: Color, + app: AppManager?, + manager: WalletManager?, +) { + val isDark = !MaterialTheme.colorScheme.isLight + var showDeleteMenu by remember { mutableStateOf(false) } + + // icon background: same values as iOS (0.35 dark, 0.75 light) + val iconBackground = + if (isDark) { + Color.Gray.copy(alpha = 0.35f) + } else { + Color.Black.copy(alpha = 0.75f) + } + + // format the spending amount + val formattedAmount = + manager?.let { + it.displayAmount(txn.spendingAmount(), showUnit = true) + } ?: txn.spendingAmount().satsStringWithUnit() + + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + // navigate to hardware export screen + val walletId = manager?.walletMetadata?.id + if (app != null && walletId != null) { + val route = RouteFactory().sendHardwareExport(walletId, txn.details()) + app.pushRoute(route) + } + }, + onLongClick = { showDeleteMenu = true }, + ).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // lock icon with warning indicator (0.6 opacity on whole box like iOS) + // outer Box applies opacity to entire contents including background + Box(modifier = Modifier.graphicsLayer { alpha = 0.6f }) { + Box( + modifier = + Modifier + .size(50.dp) + .clip(RoundedCornerShape(6.dp)) + .background(iconBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.LockOpen, + contentDescription = "Unsigned Transaction", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + // small warning indicator + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = Color(0xFFFF9800), + modifier = + Modifier + .size(14.dp) + .align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp), + ) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = txn.label(), + color = primaryText.copy(alpha = 0.4f), + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.pending_signature), + color = Color(0xFFFF9800), + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = formattedAmount, + color = primaryText.copy(alpha = 0.6f), + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + ) + } + } + + // delete dropdown menu + DropdownMenu( + expanded = showDeleteMenu, + onDismissRequest = { showDeleteMenu = false }, + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showDeleteMenu = false + try { + manager?.rust?.deleteUnsignedTransaction(txn.id()) + } catch (e: Exception) { + android.util.Log.e("UnsignedTxWidget", "Failed to delete unsigned transaction", e) + } + }, + ) + } + } +} + @Composable private fun BalanceWidget( hidden: Boolean, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3daa4e21..2ad790ef3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -47,7 +47,9 @@ Transactions No transactions yet Pending + Pending Signature Unconfirmed + Delete Transaction Sent Transaction Received Transaction Pending From f79025c5f716e12cf1dd55a1ad1559e80d45dc7d Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 11:39:56 -0600 Subject: [PATCH 51/97] Remove duplicate approximate prefix from fiat amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fiat formatting function already includes the ≈ symbol. --- .../cove/transaction_details/TransactionDetailsScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index b8dd06f19..c43b62708 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -865,7 +865,7 @@ private fun TransactionDetailsWidget( DetailsWidget( label = stringResource(R.string.label_network_fee), primary = transactionDetails.feeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $feeFiatFmt", + secondary = feeFiatFmt, showInfoIcon = true, onInfoClick = { /* TODO: show fee info */ }, ) @@ -874,7 +874,7 @@ private fun TransactionDetailsWidget( DetailsWidget( label = stringResource(R.string.label_recipient_receives), primary = transactionDetails.sentSansFeeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $sentSansFeeFiatFmt", + secondary = sentSansFeeFiatFmt, ) Spacer(Modifier.height(24.dp)) @@ -890,7 +890,7 @@ private fun TransactionDetailsWidget( DetailsWidget( label = stringResource(R.string.label_total_spent), primary = transactionDetails.amountFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), - secondary = "≈ $totalSpentFiatFmt", + secondary = totalSpentFiatFmt, isTotal = true, ) } else { From 24cd68dfe4b52d1afdf41256af3a6148eca36dd2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 11:40:02 -0600 Subject: [PATCH 52/97] Fix HardwareExportScreen dark mode text and button padding Add explicit onSurface color to Text components for proper dark mode rendering. Increase Export/Import button vertical padding to 16dp. --- .../bitcoinppl/cove/HardwareExportScreen.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt index 78718652e..30d2ac30b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt @@ -288,6 +288,7 @@ fun HardwareExportScreen( text = "You're sending", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 6.dp), ) @@ -315,10 +316,12 @@ fun HardwareExportScreen( text = walletManager.amountFmt(details.sendingAmount()), fontSize = 48.sp, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, ) Text( text = if (metadata?.selectedUnit?.name == "SAT") "sats" else "btc", + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .padding(start = 8.dp, bottom = 10.dp), @@ -738,6 +741,7 @@ private fun AccountSection(metadata: WalletMetadata?) { text = name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, ) } } @@ -770,6 +774,7 @@ private fun AddressSection( text = address, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.End, modifier = Modifier @@ -807,10 +812,11 @@ private fun SignTransactionSection( contentColor = CoveColor.midnightBlue, ), shape = RoundedCornerShape(10.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 12.dp, - vertical = 12.dp, - ), + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 16.dp, + ), ) { Icon( Icons.Default.Output, @@ -836,10 +842,11 @@ private fun SignTransactionSection( contentColor = CoveColor.midnightBlue, ), shape = RoundedCornerShape(10.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 12.dp, - vertical = 12.dp, - ), + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 16.dp, + ), ) { Icon( Icons.AutoMirrored.Filled.Input, From add2547ac0e1a1870f68a01e1e4bf2542792bcf8 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 11:40:09 -0600 Subject: [PATCH 53/97] Document Android/iOS platform behavior differences Add sections on list key serialization, opacity/alpha behavior, and text colors with LocalContentColor in dark mode. --- ARCHITECTURE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4042d7e82..170f63870 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,6 +149,42 @@ This pattern is used throughout the codebase for shared resources and is safe to } ``` +**List key serialization:** + +- **Android**: `LazyColumn`'s `items(key = ...)` serializes keys to a `Bundle` for state restoration (configuration changes, process death). Keys must be primitive types (String, Int) or `Parcelable` objects. +- **iOS**: SwiftUI's `ForEach` uses `Identifiable` for in-memory diffing only—IDs are never serialized, so custom types like `TxId` work directly. +- **Guideline**: When using FFI types as list keys on Android, convert to String: `key = { it.id().toString() }`. + +**Opacity/alpha:** + +- **Terminology**: Android/Compose uses `alpha`, iOS/SwiftUI uses `opacity`. Both mean the same thing (0 = transparent, 1 = opaque). +- **Container-level opacity**: On iOS, `.opacity(0.6)` applies to the entire view including its background. On Android, `Modifier.graphicsLayer { alpha = 0.6f }` only affects the composable's *content*, not modifiers like `.background()` applied to the same composable. +- **Guideline**: To match iOS's `.opacity()` behavior on Android, wrap the content in an outer Box with `graphicsLayer`: + ```kotlin + // Android - wrapper applies opacity to everything inside + Box(modifier = Modifier.graphicsLayer { alpha = 0.6f }) { + Box(modifier = Modifier.background(color)) { + // content + } + } + ``` + ```swift + // iOS equivalent + Box(...) + .background(color) + .opacity(0.6) + ``` + +**Text colors and dark mode:** + +- **iOS/SwiftUI**: `Text` uses `.primary` foreground color by default, which automatically adapts to light/dark mode without explicit color specification. +- **Android/Compose**: `Text` uses `LocalContentColor.current` by default, but this must be provided by a parent composable. Without a provider, text may render as black regardless of theme. +- **Which composables set LocalContentColor?** + - `Surface` → sets `LocalContentColor` to its `contentColor` parameter (defaults to `onSurface`) + - `Scaffold` → sets appropriate content colors for each slot + - `Column`/`Box` with `.background()` → does NOT set `LocalContentColor` +- **Guideline**: For content areas needing dark mode support, either use `Surface` instead of `Column` with `.background()`, or explicitly set `color = MaterialTheme.colorScheme.onSurface` on Text components. + ### Manager Pattern (cross-platform) 1. UI calls `manager.dispatch(action)` or a helper method (e.g. `importWallet`). From f03be24dc35c22aaf86ad9b0e610181f44473e1a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 11:40:15 -0600 Subject: [PATCH 54/97] Minor code formatting cleanup --- .../src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt | 9 +++++---- .../main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt index 5157a4cff..468b275c4 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeGenerator.kt @@ -20,10 +20,11 @@ object QrCodeGenerator { size: Int = 512, errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.L, ): Bitmap { - val hints = mapOf( - EncodeHintType.ERROR_CORRECTION to errorCorrectionLevel, - EncodeHintType.MARGIN to 0, - ) + val hints = + mapOf( + EncodeHintType.ERROR_CORRECTION to errorCorrectionLevel, + EncodeHintType.MARGIN to 0, + ) val writer = QRCodeWriter() val bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt index e02db6f4c..83d1febe5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.bitcoinppl.cove.AppAlertState -import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove_core.multiFormatTryFromNfcMessage import org.bitcoinppl.cove_core.nfc.NfcMessage From 67e0a9574695e7a4c8358f8012cbd24c5f1d0fd8 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 14:12:12 -0600 Subject: [PATCH 55/97] Fix Android transaction label editing and display TransactionLabelView: - Fix label editing not working (onFocusChanged fired prematurely) - Add keyboard Done action to save label - Make pencil icon smaller (13dp) - Place cursor at end of text when editing WalletTransactionsScreen: - Show custom labels instead of Sent/Received when showLabels enabled --- .../TransactionLabelView.kt | 27 ++++++++++--- .../WalletTransactionsScreen.kt | 39 ++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt index 421fba042..b4fc6245e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete @@ -37,6 +39,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.bitcoinppl.cove.R @@ -55,10 +60,11 @@ fun TransactionLabelView( modifier: Modifier = Modifier, ) { var isEditing by remember { mutableStateOf(false) } - var editingLabel by remember { mutableStateOf("") } + var editingLabel by remember { mutableStateOf(TextFieldValue()) } var showMenu by remember { mutableStateOf(false) } var currentLabel by remember { mutableStateOf(transactionDetails.transactionLabel()) } var isOperationInProgress by remember { mutableStateOf(false) } + var hasFocusedOnce by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val context = LocalContext.current @@ -104,12 +110,13 @@ fun TransactionLabelView( val metadata = manager.walletMetadata labelManager.insertOrUpdateLabelsForTxn( details = transactionDetails, - label = editingLabel.trim(), + label = editingLabel.text.trim(), origin = metadata?.origin, ) updateDetails() isEditing = false + hasFocusedOnce = false } 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") @@ -131,7 +138,8 @@ fun TransactionLabelView( try { labelManager.deleteLabelsForTxn(txId = txId) isEditing = false - editingLabel = "" + hasFocusedOnce = false + editingLabel = TextFieldValue() currentLabel = null updateDetails() @@ -149,7 +157,8 @@ fun TransactionLabelView( } fun setEditing() { - editingLabel = currentLabel ?: "" + val text = currentLabel ?: "" + editingLabel = TextFieldValue(text = text, selection = TextRange(text.length)) isEditing = true } @@ -171,6 +180,7 @@ fun TransactionLabelView( imageVector = Icons.Default.Edit, contentDescription = null, tint = secondaryColor, + modifier = Modifier.size(13.dp), ) Spacer(Modifier.width(8.dp)) @@ -184,12 +194,16 @@ fun TransactionLabelView( fontSize = 13.sp, ), cursorBrush = SolidColor(secondaryColor), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { saveLabel() }), modifier = Modifier .focusRequester(focusRequester) .onFocusChanged { focusState -> - // auto-save when focus is lost - if (!focusState.isFocused && isEditing) { + if (focusState.isFocused) { + hasFocusedOnce = true + } else if (hasFocusedOnce && isEditing) { + // only save when focus is actually lost (was focused, now unfocused) saveLabel() } }, @@ -218,6 +232,7 @@ fun TransactionLabelView( imageVector = Icons.Default.Edit, contentDescription = null, tint = secondaryColor, + modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(8.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index c3534a67e..40b9109a5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -428,6 +428,18 @@ fun WalletTransactionsScreen( TransactionDirection.OUTGOING -> TransactionType.SENT } + val txLabel = + if (manager?.walletMetadata?.showLabels == true) { + txn.v1.label() + } else { + stringResource( + when (txType) { + TransactionType.SENT -> R.string.label_transaction_sent + TransactionType.RECEIVED -> R.string.label_transaction_received + }, + ) + } + // format amount with manager if available val formattedAmount = manager?.let { @@ -438,6 +450,7 @@ fun WalletTransactionsScreen( TransactionWidget( type = txType, + label = txLabel, date = txn.v1.confirmedAtFmt(), amount = formattedAmount, balanceAfter = txn.v1.blockHeightFmt(), @@ -458,6 +471,18 @@ fun WalletTransactionsScreen( TransactionDirection.OUTGOING -> TransactionType.SENT } + val txLabel = + if (manager?.walletMetadata?.showLabels == true) { + txn.v1.label() + } else { + stringResource( + when (txType) { + TransactionType.SENT -> R.string.label_transaction_sent + TransactionType.RECEIVED -> R.string.label_transaction_received + }, + ) + } + // format amount with manager if available val formattedAmount = manager?.let { @@ -468,6 +493,7 @@ fun WalletTransactionsScreen( TransactionWidget( type = txType, + label = txLabel, date = stringResource(R.string.pending), amount = formattedAmount, balanceAfter = stringResource(R.string.unconfirmed), @@ -503,6 +529,7 @@ fun WalletTransactionsScreen( @Composable private fun TransactionWidget( type: TransactionType, + label: String, date: String, amount: String, balanceAfter: String, @@ -513,14 +540,6 @@ private fun TransactionWidget( app: AppManager?, manager: WalletManager?, ) { - val title = - stringResource( - when (type) { - TransactionType.SENT -> R.string.label_transaction_sent - TransactionType.RECEIVED -> R.string.label_transaction_received - }, - ) - val scope = rememberCoroutineScope() val isDark = !MaterialTheme.colorScheme.isLight @@ -571,7 +590,7 @@ private fun TransactionWidget( ) { Icon( imageVector = if (type == TransactionType.SENT) Icons.Filled.NorthEast else Icons.Filled.SouthWest, - contentDescription = title, + contentDescription = label, tint = Color.White, modifier = Modifier.size(24.dp), ) @@ -584,7 +603,7 @@ private fun TransactionWidget( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = title, + text = label, color = primaryText.copy(alpha = 0.65f), fontSize = 15.sp, fontWeight = FontWeight.Medium, From 2ddfbbff50581b3a9f67caf8a214ee1350b24b8d Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 14:35:58 -0600 Subject: [PATCH 56/97] Fix currency symbol in wallet header to respect settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Rust FFI displayFiatAmount() instead of hardcoded Locale.US formatter, matching iOS implementation. Now shows correct symbol (€, £, ¥, etc.) based on selected fiat currency. --- .../wallet_transactions/WalletTransactionsScreen.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 40b9109a5..91d0d85f1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -89,8 +89,6 @@ import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType import org.bitcoinppl.cove_core.types.TransactionDirection import org.bitcoinppl.cove_core.types.WalletId -import java.text.NumberFormat -import java.util.Locale enum class TransactionType { SENT, RECEIVED } @@ -150,9 +148,9 @@ fun WalletTransactionsScreen( val spendable = it.balance.spendable() it.displayAmount(spendable, showUnit = true) } ?: satsAmount - val actualUsdAmount = + val actualFiatAmount = manager?.fiatBalance?.let { - NumberFormat.getCurrencyInstance(Locale.US).format(it) + manager.rust.displayFiatAmount(it) } ?: usdAmount val transactions = when (val state = manager?.loadState) { @@ -307,8 +305,8 @@ fun WalletTransactionsScreen( val fiatOrBtc = manager?.walletMetadata?.fiatOrBtc ?: FiatOrBtc.BTC val (primaryAmount, secondaryAmount) = when (fiatOrBtc) { - FiatOrBtc.FIAT -> actualUsdAmount to actualSatsAmount - FiatOrBtc.BTC -> actualSatsAmount to actualUsdAmount + FiatOrBtc.FIAT -> actualFiatAmount to actualSatsAmount + FiatOrBtc.BTC -> actualSatsAmount to actualFiatAmount } BalanceWidget( From 2768327dd4cc5cdb782e3622360e1058915e1f75 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 14:44:14 -0600 Subject: [PATCH 57/97] Restyle Android settings screens with AOSP-style section headers - Add divider + accent-colored headers between sections (AOSP pattern) - Add showDivider param to SectionHeader, false for first section - Change network icon from WiFi to globe (Public) - Fix wallet color picker padding to match other items (16dp) - Add warning color to View Secret Words, error color to Delete Wallet - Add trash icon to Delete Wallet button - Add titleColor param to MaterialSettingsItem for colored text --- .../cove/settings/AppearanceSettingsScreen.kt | 2 +- .../settings/FiatCurrencySettingsScreen.kt | 2 +- .../cove/settings/NetworkSettingsScreen.kt | 2 +- .../cove/settings/NodeSettingsScreen.kt | 2 +- .../cove/settings/SettingsScreen.kt | 6 ++-- .../cove/settings/WalletSettingsScreen.kt | 16 +++++++-- .../bitcoinppl/cove/views/CustomBaseViews.kt | 35 ++++++++++++------- .../org/bitcoinppl/cove/views/SettingsItem.kt | 2 ++ 8 files changed, 45 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/AppearanceSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/AppearanceSettingsScreen.kt index 280d6655f..a7f47eef7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/AppearanceSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/AppearanceSettingsScreen.kt @@ -85,7 +85,7 @@ fun AppearanceSettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_settings_appearance)) + SectionHeader(stringResource(R.string.title_settings_appearance), showDivider = false) MaterialSection { Column { colorSchemes.forEachIndexed { index, colorScheme -> diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/FiatCurrencySettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/FiatCurrencySettingsScreen.kt index e5a74b925..7c55b88d4 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/FiatCurrencySettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/FiatCurrencySettingsScreen.kt @@ -86,7 +86,7 @@ fun FiatCurrencySettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_settings_currency)) + SectionHeader(stringResource(R.string.title_settings_currency), showDivider = false) MaterialSection { Column { fiatCurrencies.forEachIndexed { index, fiatCurrency -> diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt index d37d2f80e..f8a6e588d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt @@ -91,7 +91,7 @@ fun NetworkSettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_settings_network)) + SectionHeader(stringResource(R.string.title_settings_network), showDivider = false) MaterialSection { Column { networks.forEachIndexed { index, network -> diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt index f2545379b..74960db4d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NodeSettingsScreen.kt @@ -265,7 +265,7 @@ fun NodeSettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_settings_node)) + SectionHeader(stringResource(R.string.title_settings_node), showDivider = false) MaterialSection { Column { // preset nodes diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index b82a085de..0bc8cc206 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.material.icons.filled.Masks import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -135,12 +135,12 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_settings_general)) + SectionHeader(stringResource(R.string.title_settings_general), showDivider = false) MaterialSection { Column { MaterialSettingsItem( title = stringResource(R.string.title_settings_network), - icon = Icons.Default.Wifi, + icon = Icons.Default.Public, onClick = { app.pushRoute( org.bitcoinppl.cove_core.Route diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt index 116877ee7..ef32f2a51 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -54,6 +55,7 @@ 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.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.MaterialSpacing import org.bitcoinppl.cove.utils.toComposeColor import org.bitcoinppl.cove.views.AutoSizeText @@ -152,7 +154,7 @@ fun WalletSettingsScreen( .verticalScroll(rememberScrollState()) .padding(paddingValues), ) { - SectionHeader(stringResource(R.string.title_wallet_information)) + SectionHeader(stringResource(R.string.title_wallet_information), showDivider = false) MaterialSection { Column { MaterialSettingsItem( @@ -229,6 +231,7 @@ fun WalletSettingsScreen( if (metadata.walletType == WalletType.HOT) { MaterialSettingsItem( title = stringResource(R.string.label_wallet_view_secrets), + titleColor = CoveColor.WarningOrange, onClick = { app.pushRoute(Route.SecretWords(metadata.id)) }, @@ -238,6 +241,15 @@ fun WalletSettingsScreen( if (dangerItemCount > 0) MaterialDivider() MaterialSettingsItem( title = stringResource(R.string.label_wallet_delete), + titleColor = MaterialTheme.colorScheme.error, + leadingContent = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + }, onClick = { showDeleteConfirmation = true }, @@ -342,7 +354,7 @@ private fun WalletColorSelector( Column( Modifier .fillMaxWidth() - .padding(8.dp), + .padding(MaterialSpacing.medium), ) { Text( modifier = 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 1d17a4593..07dbc796e 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 @@ -236,24 +236,33 @@ fun CardItemPreview() { CardItem("name") { Text("hello") } } -// Material Design section header (sentence case, labelLarge) +// Material Design section header (AOSP style with divider and accent color) @Composable fun SectionHeader( title: String, modifier: Modifier = Modifier, + showDivider: Boolean = true, ) { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - modifier - .fillMaxWidth() - .padding( - horizontal = MaterialSpacing.medium, - vertical = 12.dp, - ), - ) + Column(modifier = modifier.fillMaxWidth()) { + if (showDivider) { + Spacer(modifier = Modifier.height(MaterialSpacing.medium)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .fillMaxWidth() + .padding( + start = MaterialSpacing.medium, + end = MaterialSpacing.medium, + top = 12.dp, + bottom = 4.dp, + ), + ) + } } @Preview diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt index b13591a7d..d19062a47 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt @@ -98,12 +98,14 @@ fun MaterialSettingsItem( subtitle: String? = null, leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, + titleColor: Color? = null, ) { ListItem( headlineContent = { Text( text = title, style = MaterialTheme.typography.bodyLarge, + color = titleColor ?: Color.Unspecified, ) }, supportingContent = From 0434fa2ee95e08fd8d303a053e85846868358d62 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:10:09 -0600 Subject: [PATCH 58/97] Add NFC scanning feedback and wallet import success dialog NFC scanning now shows visual feedback matching iOS: - Animated "Reading..." dots when tag is detected - Green checkmark with "Tag read successfully!" before closing - Added NfcReadingState enum to track scan progress Wallet import success now shows as modal AlertDialog instead of brief snackbar that was easy to miss. Updated all three NFC sheets: NfcScanSheet, NfcLabelImportSheet, and HotWalletImportScreen's NfcScannerSheet. --- .../java/org/bitcoinppl/cove/AppAlertState.kt | 1 - .../java/org/bitcoinppl/cove/MainActivity.kt | 11 ++ .../hot_wallet/HotWalletImportScreen.kt | 136 ++++++++++++----- .../cove/nfc/NfcLabelImportSheet.kt | 143 +++++++++++++----- .../java/org/bitcoinppl/cove/nfc/NfcReader.kt | 55 ++++++- .../org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 50 ++++++ 6 files changed, 313 insertions(+), 83 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt index 0cbf04e0b..2041b3d1b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppAlertState.kt @@ -163,7 +163,6 @@ sealed class AppAlertState { fun isSnackbar(): Boolean = when (this) { - is ImportedSuccessfully, is ImportedLabelsSuccessfully, is InvalidWordGroup, is ErrorImportingHotWallet, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 09b1e70e2..ac0b8b989 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -518,6 +518,17 @@ private fun GlobalAlertDialog( ) } + is AppAlertState.ImportedSuccessfully -> { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(state.title()) }, + text = { Text(state.message()) }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + ) + } + else -> { AlertDialog( onDismissRequest = onDismiss, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index 6e4492c26..0174c4103 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -30,6 +31,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.AlertDialog @@ -93,6 +95,7 @@ import kotlinx.coroutines.delay import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.ImportWalletManager import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.nfc.NfcReadingState import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove.views.DashDotsIndicator @@ -1113,47 +1116,106 @@ private fun NfcScannerSheet( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (nfcReader.isScanning) { - CircularProgressIndicator( - color = Color.White, - modifier = Modifier.padding(16.dp), - ) + val readingState = nfcReader.readingState + + when (readingState) { + NfcReadingState.SUCCESS -> { + // success state - show checkmark + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(48.dp), + tint = Color(0xFF4CAF50), // green + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = nfcReader.message.ifEmpty { "Tag read successfully!" }, + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + NfcReadingState.TAG_DETECTED, NfcReadingState.READING -> { + // reading state - show animated dots + var dotCount by remember { mutableIntStateOf(1) } + + LaunchedEffect(Unit) { + while (true) { + delay(300) + dotCount = (dotCount % 3) + 1 + } + } - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(16.dp), - ) + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.padding(16.dp), + ) - Text( - text = "Ready to Scan", - style = MaterialTheme.typography.title3, - fontWeight = FontWeight.Bold, - color = Color.White, - ) + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) - Text( - text = nfcReader.message, - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - ) - } else { - // show icon and error message when not scanning - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(16.dp), - ) + Text( + text = "Reading" + ".".repeat(dotCount), + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) - Text( - text = "NFC Unavailable", - style = MaterialTheme.typography.title3, - fontWeight = FontWeight.Bold, - color = Color.White, - ) + Text( + text = "Please hold still", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } + NfcReadingState.WAITING -> { + if (nfcReader.isScanning) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Text( + text = "Ready to Scan", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + + Text( + text = nfcReader.message, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } else { + // show icon and error message when not scanning + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Text( + text = "NFC Unavailable", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + } } // show error message regardless of scanning state diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt index b62db2ead..88acf5155 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Nfc import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -30,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.title3 @@ -127,47 +131,106 @@ fun NfcLabelImportSheet( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (nfcReader.isScanning) { - CircularProgressIndicator( - color = Color.White, - modifier = Modifier.padding(16.dp), - ) - - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(16.dp), - ) - - Text( - text = "Ready to Scan", - style = MaterialTheme.typography.title3, - fontWeight = FontWeight.Bold, - color = Color.White, - ) - - Text( - text = nfcReader.message, - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - ) - } else { - // show icon when not scanning - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(16.dp), - ) + val readingState = nfcReader.readingState + + when (readingState) { + NfcReadingState.SUCCESS -> { + // success state - show checkmark + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(48.dp), + tint = Color(0xFF4CAF50), // green + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = nfcReader.message.ifEmpty { "Tag read successfully!" }, + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + NfcReadingState.TAG_DETECTED, NfcReadingState.READING -> { + // reading state - show animated dots + var dotCount by remember { mutableIntStateOf(1) } + + LaunchedEffect(Unit) { + while (true) { + delay(300) + dotCount = (dotCount % 3) + 1 + } + } - Text( - text = "NFC Unavailable", - style = MaterialTheme.typography.title3, - fontWeight = FontWeight.Bold, - color = Color.White, - ) + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Text( + text = "Reading" + ".".repeat(dotCount), + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + + Text( + text = "Please hold still", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } + NfcReadingState.WAITING -> { + if (nfcReader.isScanning) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Text( + text = "Ready to Scan", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + + Text( + text = nfcReader.message, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } else { + // show icon when not scanning + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Text( + text = "NFC Unavailable", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt index ad64b4d10..74c5bbc19 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt @@ -6,6 +6,8 @@ import android.nfc.NfcAdapter import android.nfc.Tag import android.nfc.tech.Ndef import android.nfc.tech.NdefFormatable +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,12 +17,20 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import java.nio.charset.Charset +enum class NfcReadingState { + WAITING, + TAG_DETECTED, + READING, + SUCCESS, +} + class NfcReader( private val activity: Activity, ) { private val nfcAdapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity) private val _scanResults = Channel(Channel.BUFFERED) val scanResults: Flow = _scanResults.receiveAsFlow() + private val mainHandler = Handler(Looper.getMainLooper()) var isScanning by mutableStateOf(false) private set @@ -28,6 +38,9 @@ class NfcReader( var message by mutableStateOf("") private set + var readingState by mutableStateOf(NfcReadingState.WAITING) + private set + fun startScanning() { if (nfcAdapter == null) { _scanResults.trySend(NfcScanResult.Error("NFC is not supported on this device")) @@ -41,6 +54,7 @@ class NfcReader( isScanning = true message = "Hold your phone near the NFC tag" + readingState = NfcReadingState.WAITING nfcAdapter.enableReaderMode( activity, @@ -59,16 +73,28 @@ class NfcReader( fun stopScanning() { isScanning = false message = "" + readingState = NfcReadingState.WAITING nfcAdapter?.disableReaderMode(activity) } private fun handleTag(tag: Tag) { Log.d("NfcReader", "Tag detected: ${tag.techList.joinToString()}") + // update state on main thread - tag detected! + mainHandler.post { + readingState = NfcReadingState.TAG_DETECTED + message = "Reading" + } + try { // try reading NDEF data val ndef = Ndef.get(tag) if (ndef != null) { + // update state on main thread - now reading + mainHandler.post { + readingState = NfcReadingState.READING + } + ndef.connect() val ndefMessage = ndef.ndefMessage ndef.close() @@ -138,21 +164,40 @@ class NfcReader( } } - if (textContent.isNotBlank()) { - _scanResults.trySend(NfcScanResult.Success(textContent, binaryData)) - } else if (binaryData != null) { - _scanResults.trySend(NfcScanResult.Success(null, binaryData)) + if (textContent.isNotBlank() || binaryData != null) { + // set SUCCESS state and show success message + mainHandler.post { + readingState = NfcReadingState.SUCCESS + message = "Tag read successfully!" + } + + // delay sending the result so UI can show success message + val result = + if (textContent.isNotBlank()) { + NfcScanResult.Success(textContent, binaryData) + } else { + NfcScanResult.Success(null, binaryData) + } + + mainHandler.postDelayed({ + _scanResults.trySend(result) + stopScanning() + }, SUCCESS_DISPLAY_DELAY_MS) } else { _scanResults.trySend(NfcScanResult.Error("No readable data found on NFC tag")) + stopScanning() } + } - stopScanning() + companion object { + private const val SUCCESS_DISPLAY_DELAY_MS = 1000L } fun reset() { stopScanning() isScanning = false message = "" + readingState = NfcReadingState.WAITING } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt index 83d1febe5..2701142f9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -20,14 +23,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem @@ -123,6 +129,8 @@ fun NfcScanSheet( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + val readingState = nfcReader?.readingState ?: NfcReadingState.WAITING + if (errorMessage != null) { Text( text = "Error", @@ -143,7 +151,49 @@ fun NfcScanSheet( }) { Text("Try Again") } + } else if (readingState == NfcReadingState.SUCCESS) { + // success state - show checkmark + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(48.dp), + tint = Color(0xFF4CAF50), // green + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = nfcReader?.message ?: "Tag read successfully!", + style = MaterialTheme.typography.title3, + ) + } else if (readingState == NfcReadingState.TAG_DETECTED || + readingState == NfcReadingState.READING + ) { + // reading state - show animated dots + var dotCount by remember { mutableIntStateOf(1) } + + LaunchedEffect(Unit) { + while (true) { + delay(300) + dotCount = (dotCount % 3) + 1 + } + } + + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Reading" + ".".repeat(dotCount), + style = MaterialTheme.typography.title3, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Please hold still", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } else { + // waiting state - ready to scan CircularProgressIndicator( modifier = Modifier.size(48.dp), ) From dcd8a521cafa27026b4962b132144e71ea83c2c7 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:10:31 -0600 Subject: [PATCH 59/97] Fix NFC hardware wallet import to match iOS flow Use global NfcScanSheet instead of navigating to HotWalletImportScreen. Now NFC import shows the scan sheet as a modal overlay on the current page and imports directly as a cold wallet on success. --- .../main/java/org/bitcoinppl/cove/NewWalletContainer.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/NewWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/NewWalletContainer.kt index 3c7f0ed3b..0c5cc8473 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/NewWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/NewWalletContainer.kt @@ -39,13 +39,7 @@ fun NewWalletContainer( app.pushRoute(RouteFactory().qrImport()) }, onOpenNfcScan = { - app.pushRoute( - HotWalletRoute - .Import( - v1 = NumberOfBip39Words.TWENTY_FOUR, - v2 = ImportType.NFC, - ).intoRoute(), - ) + app.scanNfc() }, ) } From d084898ce5d4a25134d3b7239bcf974f8f3e5bd5 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:27:37 -0600 Subject: [PATCH 60/97] Refactor ImageButton with AutoSizeText and dynamic icon scaling Change leading from composable lambda to Painter for consistency. Add AutoSizeText with proportional icon scaling based on text size to handle long button labels gracefully. --- .../flow/new_wallet/NewWalletSelectScreen.kt | 15 +---- .../org/bitcoinppl/cove/views/ImageButton.kt | 63 ++++++++++++++----- .../WalletTransactionsScreen.kt | 15 +---- 3 files changed, 52 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt index fa11d85e9..12d4ea587 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/NewWalletSelectScreen.kt @@ -293,12 +293,7 @@ fun NewWalletSelectScreen( ) { ImageButton( text = stringResource(R.string.btn_hardware_wallet), - leading = { - Icon( - painter = painterResource(R.drawable.icon_currency_bitcoin), - contentDescription = null, - ) - }, + leadingIcon = painterResource(R.drawable.icon_currency_bitcoin), onClick = { showHardwareWalletSheet = true }, @@ -310,15 +305,9 @@ fun NewWalletSelectScreen( modifier = Modifier.weight(1f), ) - // placeholder "phone" ImageButton( text = stringResource(R.string.btn_on_this_device), - leading = { - Icon( - painter = painterResource(R.drawable.icon_phone_device), - contentDescription = null, - ) - }, + leadingIcon = painterResource(R.drawable.icon_phone_device), onClick = onOpenNewHotWallet, colors = ButtonDefaults.buttonColors( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index 1466eb235..04b11f115 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -1,8 +1,11 @@ package org.bitcoinppl.cove.views +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -11,25 +14,34 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.ui.theme.CoveColor @Composable fun ImageButton( modifier: Modifier = Modifier, text: String, - leading: @Composable (() -> Unit)? = null, + leadingIcon: Painter? = null, onClick: () -> Unit, colors: ButtonColors = ButtonDefaults.buttonColors(), enabled: Boolean = true, ) { + val maxFontSize = 14.sp + val minimumScaleFactor = 0.2f + val baseIconSize = 24.dp + Button( onClick = onClick, shape = RoundedCornerShape(10.dp), @@ -38,17 +50,36 @@ fun ImageButton( enabled = enabled, contentPadding = PaddingValues(vertical = 18.dp, horizontal = 12.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (leading != null) { - leading() - Spacer(Modifier.width(8.dp)) + BoxWithConstraints { + // track the scale factor from AutoSizeText + var scaleFactor by remember { mutableStateOf(1f) } + val iconSize = baseIconSize * scaleFactor + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (leadingIcon != null) { + Icon( + painter = leadingIcon, + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = LocalContentColor.current, + ) + Spacer(Modifier.width(8.dp * scaleFactor)) + } + AutoSizeText( + text = text, + fontWeight = FontWeight.Medium, + maxFontSize = maxFontSize, + minimumScaleFactor = minimumScaleFactor, + modifier = Modifier.weight(1f), + onTextLayout = { result -> + val computedSize = result.layoutInput.style.fontSize + scaleFactor = (computedSize.value / maxFontSize.value).coerceIn(minimumScaleFactor, 1f) + }, + ) } - Text( - text, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } } } @@ -58,9 +89,9 @@ fun ImageButton( private fun PreviewImageButton() { ImageButton( text = "Call Now", - leading = { - Icon(Icons.Default.Call, contentDescription = null) - }, + leadingIcon = + androidx.compose.ui.graphics.vector + .rememberVectorPainter(Icons.Default.Call), onClick = {}, colors = ButtonDefaults.buttonColors( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index 91d0d85f1..ca533717a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -319,12 +320,7 @@ fun WalletTransactionsScreen( Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { ImageButton( text = stringResource(R.string.btn_send), - leading = { - Icon( - imageVector = Icons.Filled.NorthEast, - contentDescription = null, - ) - }, + leadingIcon = rememberVectorPainter(Icons.Filled.NorthEast), onClick = onSend, colors = androidx.compose.material3.ButtonDefaults.buttonColors( @@ -335,12 +331,7 @@ fun WalletTransactionsScreen( ) ImageButton( text = stringResource(R.string.btn_receive), - leading = { - Icon( - imageVector = Icons.Filled.SouthWest, - contentDescription = null, - ) - }, + leadingIcon = rememberVectorPainter(Icons.Filled.SouthWest), onClick = onReceive, colors = androidx.compose.material3.ButtonDefaults.buttonColors( From 8126f19454ee2c62d63b1e363f28cdd395a87d29 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:30:24 -0600 Subject: [PATCH 61/97] Fix HardwareExportScreen amount centering with dynamic offset Calculate unit label width dynamically using onSizeChanged and use half the width as horizontal offset for true centering. Also use AutoSizeText for the amount to handle large values. --- .../bitcoinppl/cove/HardwareExportScreen.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt index 30d2ac30b..e03b4c981 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt @@ -65,8 +65,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -300,7 +302,10 @@ fun HardwareExportScreen( ) } - // amount display - centered with offset to account for unit label + // amount display - centered with dynamic offset based on unit label width + var unitLabelWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + Column( modifier = Modifier @@ -310,11 +315,12 @@ fun HardwareExportScreen( ) { Row( verticalAlignment = Alignment.Bottom, - modifier = Modifier.offset(x = 32.dp), + modifier = Modifier.offset(x = unitLabelWidth / 2), ) { - Text( + AutoSizeText( text = walletManager.amountFmt(details.sendingAmount()), - fontSize = 48.sp, + maxFontSize = 48.sp, + minimumScaleFactor = 0.5f, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) @@ -324,7 +330,10 @@ fun HardwareExportScreen( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier - .padding(start = 8.dp, bottom = 10.dp), + .padding(start = 8.dp, bottom = 10.dp) + .onSizeChanged { size -> + unitLabelWidth = with(density) { size.width.toDp() } + }, ) } From 780e097f782f5e3401c1dd1ed828b74e21ee7e98 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:30:50 -0600 Subject: [PATCH 62/97] Clean up SettingsScreen and change decoy PIN icon Remove unused network change handling (BackHandler, alert dialog). Change decoy PIN icon from Masks to TheaterComedy for better visual representation. --- .../cove/settings/SettingsScreen.kt | 62 ++----------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index 0bc8cc206..b14376ca4 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.settings -import androidx.activity.compose.BackHandler import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.compose.foundation.background @@ -21,11 +20,11 @@ import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.Masks import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -79,18 +78,6 @@ fun SettingsScreen( app: org.bitcoinppl.cove.AppManager, modifier: Modifier = Modifier, ) { - // track if network has changed (similar to iOS implementation) - val networkChanged = - remember(app.previousSelectedNetwork, app.selectedNetwork) { - app.previousSelectedNetwork != null && app.selectedNetwork != app.previousSelectedNetwork - } - var showNetworkChangeAlert by remember { mutableStateOf(false) } - - // intercept back button when network has changed - BackHandler(enabled = networkChanged) { - showNetworkChangeAlert = true - } - Scaffold( modifier = modifier @@ -111,15 +98,7 @@ fun SettingsScreen( } }, navigationIcon = { - IconButton( - onClick = { - if (networkChanged) { - showNetworkChangeAlert = true - } else { - app.popRoute() - } - }, - ) { + IconButton(onClick = { app.popRoute() }) { Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") } }, @@ -190,39 +169,6 @@ fun SettingsScreen( } }, ) - - // network change alert - if (showNetworkChangeAlert) { - AlertDialog( - onDismissRequest = { showNetworkChangeAlert = false }, - title = { Text("⚠️ Network Changed ⚠️") }, - text = { - val networkName = - org.bitcoinppl.cove_core.types - .networkToString(app.selectedNetwork) - Text("You've changed your network to $networkName") - }, - confirmButton = { - TextButton( - onClick = { - app.rust.selectLatestOrNewWallet() - app.confirmNetworkChange() - showNetworkChangeAlert = false - app.popRoute() - }, - ) { - Text("Yes, Change Network") - } - }, - dismissButton = { - TextButton( - onClick = { showNetworkChangeAlert = false }, - ) { - Text("Cancel") - } - }, - ) - } } @Composable @@ -518,7 +464,7 @@ private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { MaterialDivider() MaterialSettingsItem( title = "Enable Decoy PIN", - icon = Icons.Default.Masks, + icon = Icons.Default.TheaterComedy, isSwitch = true, switchCheckedState = isDecoyPinEnabled, onCheckChanged = { enabled -> onDecoyPinToggle(enabled) }, From e4c30b8ee0c0bcaac82bf1cabd3e72d550aa0910 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:31:09 -0600 Subject: [PATCH 63/97] Minor formatting: adjust color selector padding and fix imports --- .../org/bitcoinppl/cove/settings/WalletSettingsScreen.kt | 7 ++++++- .../cove/transaction_details/TransactionLabelView.kt | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt index ef32f2a51..a0ee8adc2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt @@ -354,7 +354,12 @@ private fun WalletColorSelector( Column( Modifier .fillMaxWidth() - .padding(MaterialSpacing.medium), + .padding( + start = MaterialSpacing.medium, + end = MaterialSpacing.medium, + top = MaterialSpacing.medium, + bottom = MaterialSpacing.small, + ), ) { Text( modifier = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt index b4fc6245e..33da652f2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionLabelView.kt @@ -38,10 +38,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.bitcoinppl.cove.R From 99ba083ff9fd1e54fa3914a2c94a746487e0e1f8 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:35:41 -0600 Subject: [PATCH 64/97] Remove network change tracking from AppManager Remove previousSelectedNetwork state and confirmNetworkChange function since network change alert was moved to NetworkSettingsScreen only. --- .../src/main/java/org/bitcoinppl/cove/AppManager.kt | 10 ---------- .../bitcoinppl/cove/settings/NetworkSettingsScreen.kt | 3 --- 2 files changed, 13 deletions(-) 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 26c665973..1d0c27640 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -57,9 +57,6 @@ class AppManager private constructor() : FfiReconcile { var selectedNetwork by mutableStateOf(Database().globalConfig().selectedNetwork()) private set - var previousSelectedNetwork: Network? by mutableStateOf(null) - private set - var colorSchemeSelection by mutableStateOf(Database().globalConfig().colorScheme()) private set @@ -505,10 +502,6 @@ class AppManager private constructor() : FfiReconcile { rust.loadAndResetDefaultRoute(to) } - fun confirmNetworkChange() { - previousSelectedNetwork = null - } - fun agreeToTerms() { dispatch(AppAction.AcceptTerms) isTermsAccepted = true @@ -542,9 +535,6 @@ class AppManager private constructor() : FfiReconcile { } is AppStateReconcileMessage.SelectedNetworkChanged -> { - if (previousSelectedNetwork == null) { - previousSelectedNetwork = selectedNetwork - } selectedNetwork = message.v1 } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt index f8a6e588d..9f880f13c 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt @@ -126,9 +126,6 @@ fun NetworkSettingsScreen( onClick = { app.dispatch(AppAction.ChangeNetwork(network)) app.rust.selectLatestOrNewWallet() - app.confirmNetworkChange() - pendingNetworkChange = null - app.popRoute() }, ) { Text("Yes, Change Network") From 432e3d8f25cc0857442e522ae58933a2cb6c57ab Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:35:51 -0600 Subject: [PATCH 65/97] Simplify ImageButton: remove dynamic icon scaling Keep AutoSizeText for text but use fixed 24dp icon size and 8dp spacing. Dynamic scaling was overly complex and icon size reduction wasn't visually necessary. --- .../org/bitcoinppl/cove/views/ImageButton.kt | 60 +++++++------------ 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index 04b11f115..430af6b3d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.views -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,13 +15,10 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -38,10 +34,6 @@ fun ImageButton( colors: ButtonColors = ButtonDefaults.buttonColors(), enabled: Boolean = true, ) { - val maxFontSize = 14.sp - val minimumScaleFactor = 0.2f - val baseIconSize = 24.dp - Button( onClick = onClick, shape = RoundedCornerShape(10.dp), @@ -50,36 +42,26 @@ fun ImageButton( enabled = enabled, contentPadding = PaddingValues(vertical = 18.dp, horizontal = 12.dp), ) { - BoxWithConstraints { - // track the scale factor from AutoSizeText - var scaleFactor by remember { mutableStateOf(1f) } - val iconSize = baseIconSize * scaleFactor - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - if (leadingIcon != null) { - Icon( - painter = leadingIcon, - contentDescription = null, - modifier = Modifier.size(iconSize), - tint = LocalContentColor.current, - ) - Spacer(Modifier.width(8.dp * scaleFactor)) - } - AutoSizeText( - text = text, - fontWeight = FontWeight.Medium, - maxFontSize = maxFontSize, - minimumScaleFactor = minimumScaleFactor, - modifier = Modifier.weight(1f), - onTextLayout = { result -> - val computedSize = result.layoutInput.style.fontSize - scaleFactor = (computedSize.value / maxFontSize.value).coerceIn(minimumScaleFactor, 1f) - }, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (leadingIcon != null) { + Icon( + painter = leadingIcon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = LocalContentColor.current, ) + Spacer(Modifier.width(8.dp)) } + AutoSizeText( + text = text, + fontWeight = FontWeight.Medium, + maxFontSize = 14.sp, + minimumScaleFactor = 0.5f, + modifier = Modifier.weight(1f), + ) } } } @@ -89,9 +71,7 @@ fun ImageButton( private fun PreviewImageButton() { ImageButton( text = "Call Now", - leadingIcon = - androidx.compose.ui.graphics.vector - .rememberVectorPainter(Icons.Default.Call), + leadingIcon = rememberVectorPainter(Icons.Default.Call), onClick = {}, colors = ButtonDefaults.buttonColors( From efcfbf35cfbc3fab2dc2b85b3b5c770db364943a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 15:56:41 -0600 Subject: [PATCH 66/97] Fix redirect to existing wallet after network change Dismiss the network change dialog and navigate back from settings after confirming, matching iOS behavior. This prevents route conflicts when user later tries to add a new wallet from the sidebar. --- .../java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt index 9f880f13c..5ba54f629 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/NetworkSettingsScreen.kt @@ -124,8 +124,10 @@ fun NetworkSettingsScreen( confirmButton = { TextButton( onClick = { + pendingNetworkChange = null app.dispatch(AppAction.ChangeNetwork(network)) app.rust.selectLatestOrNewWallet() + app.popRoute() }, ) { Text("Yes, Change Network") From f9153ca1df0915cbd2ac7d3478509c5b06972a23 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 2 Dec 2025 20:10:41 -0600 Subject: [PATCH 67/97] Android UI polish and add Compose iOS parity docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HotWalletVerifyScreen: - Center Show Words button text - Replace TextButton with plain clickable Text for Skip Verification - Add 4dp top padding to reduce visual gap HotWalletSelectScreen: - Reduce spacing between Create and Import buttons (24dp → 12dp) ImageButton: - Add textAlign = TextAlign.Center for centered button text Documentation: - Create docs/COMPOSE_IOS_PARITY.md with platform-specific gotchas - Update ARCHITECTURE.md to link to new parity guide --- ARCHITECTURE.md | 36 +------ .../hot_wallet/HotWalletSelectScreen.kt | 2 +- .../hot_wallet/HotWalletVerifyScreen.kt | 41 +++++--- .../org/bitcoinppl/cove/views/ImageButton.kt | 43 ++++++--- docs/COMPOSE_IOS_PARITY.md | 96 +++++++++++++++++++ 5 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 docs/COMPOSE_IOS_PARITY.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 170f63870..99393b436 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,41 +149,7 @@ This pattern is used throughout the codebase for shared resources and is safe to } ``` -**List key serialization:** - -- **Android**: `LazyColumn`'s `items(key = ...)` serializes keys to a `Bundle` for state restoration (configuration changes, process death). Keys must be primitive types (String, Int) or `Parcelable` objects. -- **iOS**: SwiftUI's `ForEach` uses `Identifiable` for in-memory diffing only—IDs are never serialized, so custom types like `TxId` work directly. -- **Guideline**: When using FFI types as list keys on Android, convert to String: `key = { it.id().toString() }`. - -**Opacity/alpha:** - -- **Terminology**: Android/Compose uses `alpha`, iOS/SwiftUI uses `opacity`. Both mean the same thing (0 = transparent, 1 = opaque). -- **Container-level opacity**: On iOS, `.opacity(0.6)` applies to the entire view including its background. On Android, `Modifier.graphicsLayer { alpha = 0.6f }` only affects the composable's *content*, not modifiers like `.background()` applied to the same composable. -- **Guideline**: To match iOS's `.opacity()` behavior on Android, wrap the content in an outer Box with `graphicsLayer`: - ```kotlin - // Android - wrapper applies opacity to everything inside - Box(modifier = Modifier.graphicsLayer { alpha = 0.6f }) { - Box(modifier = Modifier.background(color)) { - // content - } - } - ``` - ```swift - // iOS equivalent - Box(...) - .background(color) - .opacity(0.6) - ``` - -**Text colors and dark mode:** - -- **iOS/SwiftUI**: `Text` uses `.primary` foreground color by default, which automatically adapts to light/dark mode without explicit color specification. -- **Android/Compose**: `Text` uses `LocalContentColor.current` by default, but this must be provided by a parent composable. Without a provider, text may render as black regardless of theme. -- **Which composables set LocalContentColor?** - - `Surface` → sets `LocalContentColor` to its `contentColor` parameter (defaults to `onSurface`) - - `Scaffold` → sets appropriate content colors for each slot - - `Column`/`Box` with `.background()` → does NOT set `LocalContentColor` -- **Guideline**: For content areas needing dark mode support, either use `Surface` instead of `Column` with `.background()`, or explicitly set `color = MaterialTheme.colorScheme.onSurface` on Text components. +**Compose ↔ iOS parity patterns:** For detailed guidance on matching iOS behavior in Compose (opacity, text colors, button centering, AutoSizeText, etc.), see [docs/COMPOSE_IOS_PARITY.md](docs/COMPOSE_IOS_PARITY.md). ### Manager Pattern (cross-platform) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt index bfa21362f..2fd4f7d90 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt @@ -173,7 +173,7 @@ fun HotWalletSelectScreen( ) Column( - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { ImageButton( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index 60a411133..d861da1c8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -11,6 +11,7 @@ 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.fillMaxHeight @@ -26,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -69,7 +71,6 @@ import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.DashDotsIndicator -import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.WordValidator import kotlin.math.hypot import kotlin.math.roundToInt @@ -358,7 +359,7 @@ fun HotWalletVerifyScreen( } Column( - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() @@ -388,28 +389,38 @@ fun HotWalletVerifyScreen( HorizontalDivider(color = Color.White.copy(alpha = 0.35f), thickness = 1.dp) - ImageButton( - text = stringResource(R.string.btn_show_words), + Button( onClick = onShowWords, + shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( containerColor = CoveColor.btnPrimary, contentColor = CoveColor.midnightBlue, ), + contentPadding = PaddingValues(vertical = 20.dp, horizontal = 10.dp), modifier = Modifier.fillMaxWidth(), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, ) { - TextButton(onClick = { showSkipAlert = true }) { - Text( - text = stringResource(R.string.btn_skip_verification), - color = Color.White.copy(alpha = 0.9f), - ) - } + Text( + text = stringResource(R.string.btn_show_words), + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } + + Text( + text = stringResource(R.string.btn_skip_verification), + color = Color.White.copy(alpha = 0.9f), + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clickable { showSkipAlert = true }, + ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index 430af6b3d..04fc753d7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -1,24 +1,29 @@ package org.bitcoinppl.cove.views -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call -import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -34,32 +39,42 @@ fun ImageButton( colors: ButtonColors = ButtonDefaults.buttonColors(), enabled: Boolean = true, ) { - Button( - onClick = onClick, + Surface( + modifier = + modifier + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = enabled, onClick = onClick), shape = RoundedCornerShape(10.dp), - colors = colors, - modifier = modifier, - enabled = enabled, - contentPadding = PaddingValues(vertical = 18.dp, horizontal = 12.dp), + color = colors.containerColor, + contentColor = colors.contentColor, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 12.dp), ) { if (leadingIcon != null) { Icon( painter = leadingIcon, contentDescription = null, modifier = Modifier.size(24.dp), - tint = LocalContentColor.current, ) Spacer(Modifier.width(8.dp)) } - AutoSizeText( + BasicText( text = text, - fontWeight = FontWeight.Medium, - maxFontSize = 14.sp, - minimumScaleFactor = 0.5f, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + minFontSize = 7.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp, + ), + style = TextStyle( + fontWeight = FontWeight.Medium, + color = LocalContentColor.current, + ), modifier = Modifier.weight(1f), ) } diff --git a/docs/COMPOSE_IOS_PARITY.md b/docs/COMPOSE_IOS_PARITY.md new file mode 100644 index 000000000..413802b09 --- /dev/null +++ b/docs/COMPOSE_IOS_PARITY.md @@ -0,0 +1,96 @@ +# Compose ↔ iOS Parity Guide + +This document covers platform-specific gotchas and patterns for achieving visual and behavioral parity between Android (Jetpack Compose) and iOS (SwiftUI). + +--- + +## List Key Serialization + +- **Android**: `LazyColumn`'s `items(key = ...)` serializes keys to a `Bundle` for state restoration (configuration changes, process death). Keys must be primitive types (String, Int) or `Parcelable` objects. +- **iOS**: SwiftUI's `ForEach` uses `Identifiable` for in-memory diffing only—IDs are never serialized, so custom types like `TxId` work directly. +- **Guideline**: When using FFI types as list keys on Android, convert to String: `key = { it.id().toString() }`. + +--- + +## Opacity / Alpha + +- **Terminology**: Android/Compose uses `alpha`, iOS/SwiftUI uses `opacity`. Both mean the same thing (0 = transparent, 1 = opaque). +- **Container-level opacity**: On iOS, `.opacity(0.6)` applies to the entire view including its background. On Android, `Modifier.graphicsLayer { alpha = 0.6f }` only affects the composable's *content*, not modifiers like `.background()` applied to the same composable. +- **Guideline**: To match iOS's `.opacity()` behavior on Android, wrap the content in an outer Box with `graphicsLayer`: + ```kotlin + // Android - wrapper applies opacity to everything inside + Box(modifier = Modifier.graphicsLayer { alpha = 0.6f }) { + Box(modifier = Modifier.background(color)) { + // content + } + } + ``` + ```swift + // iOS equivalent + Box(...) + .background(color) + .opacity(0.6) + ``` + +--- + +## Text Colors and Dark Mode + +- **iOS/SwiftUI**: `Text` uses `.primary` foreground color by default, which automatically adapts to light/dark mode without explicit color specification. +- **Android/Compose**: `Text` uses `LocalContentColor.current` by default, but this must be provided by a parent composable. Without a provider, text may render as black regardless of theme. +- **Which composables set LocalContentColor?** + - `Surface` → sets `LocalContentColor` to its `contentColor` parameter (defaults to `onSurface`) + - `Scaffold` → sets appropriate content colors for each slot + - `Column`/`Box` with `.background()` → does NOT set `LocalContentColor` +- **Guideline**: For content areas needing dark mode support, either use `Surface` instead of `Column` with `.background()`, or explicitly set `color = MaterialTheme.colorScheme.onSurface` on Text components. + +--- + +## Text Auto-Sizing + +iOS has built-in text shrinking via `minimumScaleFactor`. Android options: + +### Native TextAutoSize (Preferred) + +Use `BasicText` with `TextAutoSize` for simple auto-shrinking text: + +```kotlin +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize + +BasicText( + text = "Text that shrinks to fit", + maxLines = 1, + autoSize = TextAutoSize.StepBased(minFontSize = 7.sp, maxFontSize = 14.sp, stepSize = 0.5.sp), + style = TextStyle(color = LocalContentColor.current), +) +``` + +### Custom AutoSizeText + +Use the custom implementations in `views/AutoSizeText.kt` for: +- `BalanceAutoSizeText` - balance displays with digit-based sizing +- `AutoSizeTextField` - editable auto-sizing text fields + +**Requirement**: Parent must have bounded width (use `Modifier.fillMaxWidth()` on the container). + +--- + +## Button Text Centering + +- **iOS/SwiftUI**: Using `.frame(maxWidth: .infinity)` on a Text automatically centers it within the frame. Buttons styled with `PrimaryButtonStyle` get centered text by default. +- **Android/Compose**: `Modifier.fillMaxWidth()` on a Button makes it full-width, but the Text inside stays left-aligned by default. +- **Guideline**: For full-width buttons with centered text, add both properties to the Text inside the button: + ```kotlin + Button( + onClick = { ... }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Button Label", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + ``` +- **Note**: `ImageButton` handles text sizing internally using native `TextAutoSize`. From b2736f1da95c87ed154b95dc219c7f7213e7b5f4 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 3 Dec 2025 15:10:00 -0600 Subject: [PATCH 68/97] Update TapSigner flow to use EnterPin with Derive action --- .../app/src/main/java/org/bitcoinppl/cove/MainActivity.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index ac0b8b989..5eb591978 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -54,6 +54,7 @@ import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer import org.bitcoinppl.cove.ui.theme.CoveTheme import org.bitcoinppl.cove.views.LockView +import org.bitcoinppl.cove_core.AfterPinAction import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.TapSignerRoute import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat @@ -477,7 +478,9 @@ private fun GlobalAlertDialog( onDismiss() app.sheetState = TaggedItem( - AppSheetState.TapSigner(TapSignerRoute.InitSelect(state.tapSigner)), + AppSheetState.TapSigner( + TapSignerRoute.EnterPin(state.tapSigner, AfterPinAction.Derive) + ), ) }) { Text("Yes") } }, From 02935ff06ad82174c9e21f5c80deb43842cf85d9 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 3 Dec 2025 20:30:57 -0600 Subject: [PATCH 69/97] Fix styling on left aligned text buttons android --- .../hot_wallet/HotWalletSelectScreen.kt | 29 ++++++++++--------- .../cove/tapsigner/HiddenPinTextField.kt | 25 ++++++---------- .../org/bitcoinppl/cove/views/ImageButton.kt | 22 ++++++++------ 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt index 2fd4f7d90..77f1a1189 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt @@ -1,6 +1,7 @@ package org.bitcoinppl.cove.flow.new_wallet.hot_wallet import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,6 +40,7 @@ import androidx.compose.ui.layout.ContentScale 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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -190,19 +192,20 @@ fun HotWalletSelectScreen( modifier = Modifier.fillMaxWidth(), ) - TextButton( - onClick = { - showSheet = true - nextScreen = NextScreenDialog.Import - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.btn_wallet_import), - color = Color.White, - fontWeight = FontWeight.Medium, - ) - } + Text( + text = stringResource(R.string.btn_wallet_import), + color = Color.White, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .clickable { + showSheet = true + nextScreen = NextScreenDialog.Import + }, + ) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/HiddenPinTextField.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/HiddenPinTextField.kt index 4961f4eda..86d6e7793 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/HiddenPinTextField.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/HiddenPinTextField.kt @@ -4,22 +4,22 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay /** * hidden text field for PIN entry * displays only pin circles, actual input is invisible + * uses BasicTextField to avoid BringIntoView crash in ModalBottomSheet */ @Composable fun HiddenPinTextField( @@ -31,14 +31,15 @@ fun HiddenPinTextField( ) { val focusRequester = remember { FocusRequester() } - // request focus on first composition + // request focus after short delay to ensure layout is complete LaunchedEffect(Unit) { + delay(200) focusRequester.requestFocus() } Box(modifier = modifier) { - // hidden text field - TextField( + // hidden text field using BasicTextField to avoid BringIntoView crash + BasicTextField( value = value, onValueChange = { newValue -> // only allow digits @@ -56,18 +57,10 @@ fun HiddenPinTextField( }, modifier = Modifier - .size(0.dp) + .size(1.dp) // minimal size (0.dp can cause issues) .focusRequester(focusRequester), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), + singleLine = true, ) // clickable overlay to refocus diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index 04fc753d7..ba045fd53 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -66,15 +67,18 @@ fun ImageButton( BasicText( text = text, maxLines = 1, - autoSize = TextAutoSize.StepBased( - minFontSize = 7.sp, - maxFontSize = 14.sp, - stepSize = 0.5.sp, - ), - style = TextStyle( - fontWeight = FontWeight.Medium, - color = LocalContentColor.current, - ), + autoSize = + TextAutoSize.StepBased( + minFontSize = 7.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp, + ), + style = + TextStyle( + fontWeight = FontWeight.Medium, + color = LocalContentColor.current, + textAlign = TextAlign.Center, + ), modifier = Modifier.weight(1f), ) } From 1458c75b700d0a8e554c8e521e3470525896ca2d Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Wed, 3 Dec 2025 20:31:20 -0600 Subject: [PATCH 70/97] Use state machine in rust for verify words animation --- .../java/org/bitcoinppl/cove/MainActivity.kt | 12 +- .../hot_wallet/HotWalletVerifyScreen.kt | 412 +++--- .../hot_wallet/VerifyWordsContainer.kt | 60 +- .../java/org/bitcoinppl/cove_core/cove.kt | 899 +++++++++++- .../VerifyWords/VerifyWordsScreen.swift | 146 +- .../Sources/CoveCore/generated/cove.swift | 1293 ++++++++++++++++- rust/src/lib.rs | 1 + rust/src/manager/wallet_manager.rs | 9 +- rust/src/word_verify_state_machine.rs | 355 +++++ 9 files changed, 2775 insertions(+), 412 deletions(-) create mode 100644 rust/src/word_verify_state_machine.rs diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 5eb591978..80363ecb5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -293,8 +293,14 @@ private fun SheetContent( ) } is AppSheetState.TapSigner -> { - // TapSigner sheets would go here if needed - // Currently handled elsewhere in the app + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + TapSignerContainer( + route = state.item.route, + ) + } } } } @@ -479,7 +485,7 @@ private fun GlobalAlertDialog( app.sheetState = TaggedItem( AppSheetState.TapSigner( - TapSignerRoute.EnterPin(state.tapSigner, AfterPinAction.Derive) + TapSignerRoute.EnterPin(state.tapSigner, AfterPinAction.Derive), ), ) }) { Text("Yes") } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index d861da1c8..52a75efc1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.flow.new_wallet.hot_wallet -import android.util.Log import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -44,12 +43,14 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned @@ -59,7 +60,6 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -71,117 +71,140 @@ import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.DashDotsIndicator -import org.bitcoinppl.cove_core.WordValidator +import org.bitcoinppl.cove_core.WordCheckState +import org.bitcoinppl.cove_core.WordVerifyStateMachine import kotlin.math.hypot import kotlin.math.roundToInt -object AnimationConfig { - // Movement durations (ms): how long the chip travels to the target. - var moveDurationMsCorrect: Int = 300 - var moveDurationMsIncorrect: Int = 400 - - // Dwell time at target (ms): how long the chip stays visible after arriving. - var dwellDurationMsCorrect: Int = 1000 - var dwellDurationMsIncorrect: Int = 1000 - - // Color flip threshold (fraction of remaining distance): lower = later (0.1 late), higher = earlier (0.9 fast). - var colorFlipThresholdFractionCorrect: Float = 0.1f - var colorFlipThresholdFractionIncorrect: Float = 0.1f -} - -@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) -@Composable -private fun HotWalletVerifyScreenPreview() { - val snack = remember { SnackbarHostState() } - val validator = remember { WordValidator.preview(true) } - val options = validator.possibleWords(3u) - - HotWalletVerifyScreen( - onBack = {}, - onShowWords = {}, - onSkip = {}, - snackbarHostState = snack, - questionIndex = 3, - validator = validator, - wordNumber = 3, - options = options, - onCorrectSelected = { word -> Log.d("HotWalletPreview", "onCorrectSelected: $word") }, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun HotWalletVerifyScreen( onBack: () -> Unit, onShowWords: () -> Unit, onSkip: () -> Unit, - validator: WordValidator, - wordNumber: Int, + stateMachine: WordVerifyStateMachine, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - questionIndex: Int = 1, - options: List = emptyList(), - onCorrectSelected: (String) -> Unit = {}, + onVerificationComplete: () -> Unit = {}, ) { var actualChipWidth by remember { mutableStateOf(80.dp) } val chipHeight = 46.dp var showSkipAlert by remember { mutableStateOf(false) } - var wordPositions by remember { mutableStateOf(mapOf()) } - var targetPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var rootOffset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + // position tracking for animation + var wordPositions by remember { mutableStateOf(mapOf()) } + var targetPosition by remember { mutableStateOf(Offset.Zero) } + var rootOffset by remember { mutableStateOf(Offset.Zero) } - var animatingWord by remember { mutableStateOf(null) } + // animation state val animationX = remember { Animatable(0f) } val animationY = remember { Animatable(0f) } var travelDistance by remember { mutableStateOf(1f) } - var overlayVisible by remember { mutableStateOf(false) } - - val correctColor = CoveColor.SuccessGreen - val incorrectColor = CoveColor.ErrorRed - - LaunchedEffect(animatingWord) { - animatingWord?.let { word -> - overlayVisible = false - val startPos = wordPositions[word] - if (startPos == null) { - animatingWord = null - return@let + + // UI state derived from state machine + var checkState by remember { mutableStateOf(stateMachine.state()) } + var wordNumber by remember { mutableIntStateOf(stateMachine.wordNumber().toInt()) } + var possibleWords by remember { mutableStateOf(stateMachine.possibleWords().map { it.lowercase() }) } + val config = remember { stateMachine.config() } + + // colors derived from state - no recomputation bug possible + val overlayBg = when (checkState) { + is WordCheckState.Correct -> CoveColor.SuccessGreen + is WordCheckState.Incorrect -> CoveColor.ErrorRed + else -> CoveColor.btnPrimary + } + val overlayText = when (checkState) { + is WordCheckState.Correct, is WordCheckState.Incorrect -> Color.White + else -> CoveColor.midnightBlue + } + + // handle state machine transitions + LaunchedEffect(checkState) { + when (val state = checkState) { + is WordCheckState.Checking -> { + // animate chip to target + val word = state.word + val startPos = wordPositions[word] ?: return@LaunchedEffect + + val dist = hypot(targetPosition.x - startPos.x, targetPosition.y - startPos.y) + travelDistance = if (dist <= 0f) 1f else dist + + animationX.snapTo(startPos.x) + animationY.snapTo(startPos.y) + + val moveMs = config.moveDurationMsCorrect.toInt() + + coroutineScope { + launch { + animationX.animateTo( + targetValue = targetPosition.x, + animationSpec = tween(moveMs, easing = LinearEasing), + ) + } + launch { + animationY.animateTo( + targetValue = targetPosition.y, + animationSpec = tween(moveMs, easing = LinearEasing), + ) + } + } + + // animation complete - transition to correct/incorrect + val transition = stateMachine.animationComplete() + checkState = transition.newState } - val dist = hypot(targetPosition.x - startPos.x, targetPosition.y - startPos.y) - travelDistance = if (dist <= 0f) 1f else dist - - val isCorrect = validator.isWordCorrect(word, wordNumber.toUByte()) - val moveMs = - if (isCorrect) AnimationConfig.moveDurationMsCorrect else AnimationConfig.moveDurationMsIncorrect - val dwellMs = - if (isCorrect) AnimationConfig.dwellDurationMsCorrect else AnimationConfig.dwellDurationMsIncorrect - - animationX.snapTo(startPos.x) - animationY.snapTo(startPos.y) - overlayVisible = true - - coroutineScope { - launch { - animationX.animateTo( - targetValue = targetPosition.x, - animationSpec = tween(moveMs, easing = LinearEasing), - ) + + is WordCheckState.Correct -> { + // dwell then advance + delay(config.dwellDurationMsCorrect.toLong()) + val transition = stateMachine.dwellComplete() + checkState = transition.newState + + if (transition.shouldAdvanceWord) { + if (stateMachine.isComplete()) { + onVerificationComplete() + } else { + wordNumber = stateMachine.wordNumber().toInt() + possibleWords = stateMachine.possibleWords().map { it.lowercase() } + } } - launch { - animationY.animateTo( - targetValue = targetPosition.y, - animationSpec = tween(moveMs, easing = LinearEasing), - ) + } + + is WordCheckState.Incorrect -> { + // dwell then start return + delay(config.dwellDurationMsIncorrect.toLong()) + val transition = stateMachine.dwellComplete() + checkState = transition.newState + } + + is WordCheckState.Returning -> { + // animate back to origin + val word = state.word + val originPos = wordPositions[word] ?: return@LaunchedEffect + + val moveMs = config.moveDurationMsIncorrect.toInt() + + coroutineScope { + launch { + animationX.animateTo( + targetValue = originPos.x, + animationSpec = tween(moveMs, easing = LinearEasing), + ) + } + launch { + animationY.animateTo( + targetValue = originPos.y, + animationSpec = tween(moveMs, easing = LinearEasing), + ) + } } + + val transition = stateMachine.returnComplete() + checkState = transition.newState } - delay(moveMs.toLong()) - if (isCorrect) onCorrectSelected(word) - if (dwellMs > 0) { - delay(dwellMs.toLong()) + WordCheckState.None -> { + // idle - nothing to do } - overlayVisible = false - animatingWord = null } } @@ -189,13 +212,12 @@ fun HotWalletVerifyScreen( containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( - colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - actionIconContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + actionIconContentColor = Color.White, + navigationIconContentColor = Color.White, + ), title = { Text( stringResource(R.string.title_verify_recovery_words), @@ -218,47 +240,41 @@ fun HotWalletVerifyScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Box( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .onGloballyPositioned { coords -> - val pos = coords.positionInRoot() - rootOffset = - androidx.compose.ui.geometry - .Offset(pos.x, pos.y) - }, + modifier = Modifier + .fillMaxSize() + .padding(padding) + .onGloballyPositioned { coords -> + val pos = coords.positionInRoot() + rootOffset = Offset(pos.x, pos.y) + }, ) { Image( painter = painterResource(id = R.drawable.image_chain_code_pattern_horizontal), contentDescription = null, contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxHeight() - .align(Alignment.TopCenter), + modifier = Modifier + .fillMaxHeight() + .align(Alignment.TopCenter), ) Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(vertical = 20.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(12.dp)) Text( - text = stringResource(R.string.label_what_is_word_n, questionIndex), + text = stringResource(R.string.label_what_is_word_n, wordNumber), color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, @@ -268,6 +284,7 @@ fun HotWalletVerifyScreen( Spacer(Modifier.height(24.dp)) + // target position for chip animation BoxWithConstraints(Modifier.fillMaxWidth()) { val cellWidth = (maxWidth - 12.dp * 3) / 4 Box( @@ -275,18 +292,16 @@ fun HotWalletVerifyScreen( contentAlignment = Alignment.Center, ) { Box( - modifier = - Modifier - .width(cellWidth) - .height(chipHeight) - .onGloballyPositioned { coordinates -> - val pos = coordinates.positionInRoot() - targetPosition = - androidx.compose.ui.geometry.Offset( - pos.x - rootOffset.x, - pos.y - rootOffset.y, - ) - }, + modifier = Modifier + .width(cellWidth) + .height(chipHeight) + .onGloballyPositioned { coordinates -> + val pos = coordinates.positionInRoot() + targetPosition = Offset( + pos.x - rootOffset.x, + pos.y - rootOffset.y, + ) + }, ) { } } } @@ -296,59 +311,66 @@ fun HotWalletVerifyScreen( HorizontalDivider( color = Color.White, thickness = 1.dp, - modifier = - Modifier - .width(160.dp), + modifier = Modifier.width(160.dp), ) Spacer(Modifier.height(24.dp)) + // word options grid BoxWithConstraints(Modifier.fillMaxWidth()) { val cellWidth = (maxWidth - 12.dp * 3) / 4 actualChipWidth = cellWidth - // regular grid layout (not lazy) so we can scroll the whole screen Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { - options.chunked(4).forEach { rowItems -> + possibleWords.chunked(4).forEach { rowItems -> Row( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { rowItems.forEach { word -> Box(modifier = Modifier.weight(1f)) { - if (animatingWord == word && overlayVisible) { + val isAnimating = checkState.let { + when (it) { + is WordCheckState.Checking -> it.word == word + is WordCheckState.Correct -> it.word == word + is WordCheckState.Incorrect -> it.word == word + is WordCheckState.Returning -> it.word == word + WordCheckState.None -> false + } + } + + if (isAnimating) { + // placeholder while animating Box( - modifier = - Modifier - .fillMaxWidth() - .height(46.dp), + modifier = Modifier + .fillMaxWidth() + .height(46.dp), ) { } } else { OptionChip( text = word, selected = false, onClick = { - if (animatingWord == null) { - animatingWord = word + if (checkState == WordCheckState.None) { + val transition = stateMachine.selectWord(word) + checkState = transition.newState } }, onPositionCaptured = { position -> wordPositions = wordPositions + ( - word to - androidx.compose.ui.geometry.Offset( - position.x - rootOffset.x, - position.y - rootOffset.y, - ) + word to Offset( + position.x - rootOffset.x, + position.y - rootOffset.y, + ) ) }, ) } } } - // fill remaining slots if row is not complete repeat(4 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) } @@ -360,10 +382,9 @@ fun HotWalletVerifyScreen( Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { DashDotsIndicator( @@ -392,11 +413,10 @@ fun HotWalletVerifyScreen( Button( onClick = onShowWords, shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = CoveColor.btnPrimary, - contentColor = CoveColor.midnightBlue, - ), + colors = ButtonDefaults.buttonColors( + containerColor = CoveColor.btnPrimary, + contentColor = CoveColor.midnightBlue, + ), contentPadding = PaddingValues(vertical = 20.dp, horizontal = 10.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -415,11 +435,10 @@ fun HotWalletVerifyScreen( fontWeight = FontWeight.Medium, fontSize = 12.sp, textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 4.dp) - .clickable { showSkipAlert = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clickable { showSkipAlert = true }, ) } } @@ -452,31 +471,32 @@ fun HotWalletVerifyScreen( ) } - if (animatingWord != null && overlayVisible) { - val word = animatingWord!! - val isCorrect = validator.isWordCorrect(word, wordNumber.toUByte()) - val remaining = hypot(targetPosition.x - animationX.value, targetPosition.y - animationY.value) - val threshold = if (isCorrect) AnimationConfig.colorFlipThresholdFractionCorrect else AnimationConfig.colorFlipThresholdFractionIncorrect - val nearTarget = travelDistance > 0f && (remaining / travelDistance) < threshold.coerceIn(0f, 1f) - val overlayBg = if (nearTarget) (if (isCorrect) correctColor else incorrectColor) else CoveColor.btnPrimary - val overlayText = if (nearTarget) Color.White else CoveColor.midnightBlue + // animated overlay chip + val currentWord = when (val state = checkState) { + is WordCheckState.Checking -> state.word + is WordCheckState.Correct -> state.word + is WordCheckState.Incorrect -> state.word + is WordCheckState.Returning -> state.word + WordCheckState.None -> null + } + if (currentWord != null) { Box( - modifier = - Modifier - .offset { - IntOffset( - animationX.value.roundToInt(), - animationY.value.roundToInt(), - ) - }.width(actualChipWidth) - .height(chipHeight) - .background(overlayBg, RoundedCornerShape(14.dp)) - .zIndex(10f), + modifier = Modifier + .offset { + IntOffset( + animationX.value.roundToInt(), + animationY.value.roundToInt(), + ) + } + .width(actualChipWidth) + .height(chipHeight) + .background(overlayBg, RoundedCornerShape(14.dp)) + .zIndex(10f), contentAlignment = Alignment.Center, ) { Text( - text = word, + text = currentWord, color = overlayText, fontWeight = FontWeight.Medium, maxLines = 1, @@ -493,29 +513,27 @@ private fun OptionChip( selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, - onPositionCaptured: (androidx.compose.ui.geometry.Offset) -> Unit = {}, + onPositionCaptured: (Offset) -> Unit = {}, ) { val shape = RoundedCornerShape(14.dp) val bg = if (selected) Color.White else CoveColor.btnPrimary val textColor = if (selected) CoveColor.midnightBlue else CoveColor.midnightBlue Box( - modifier = - modifier - .fillMaxWidth() - .height(46.dp), + modifier = modifier + .fillMaxWidth() + .height(46.dp), contentAlignment = Alignment.Center, ) { Box( - modifier = - Modifier - .fillMaxSize() - .clip(shape) - .background(bg, shape) - .clickable { onClick() } - .onGloballyPositioned { coordinates -> - onPositionCaptured(coordinates.positionInRoot()) - }, + modifier = Modifier + .fillMaxSize() + .clip(shape) + .background(bg, shape) + .clickable { onClick() } + .onGloballyPositioned { coordinates -> + onPositionCaptured(coordinates.positionInRoot()) + }, contentAlignment = Alignment.Center, ) { AutoSizeText( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/VerifyWordsContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/VerifyWordsContainer.kt index ead333963..12258783b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/VerifyWordsContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/VerifyWordsContainer.kt @@ -9,51 +9,42 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.WalletManager import org.bitcoinppl.cove.components.FullPageLoadingView -import org.bitcoinppl.cove_core.* -import org.bitcoinppl.cove_core.types.* +import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.WordVerifyStateMachine +import org.bitcoinppl.cove_core.types.WalletId /** - * lifecycle container for verify words flow - * manages WalletManager and WordValidator lifecycle - * shows either VerifyWordsScreen or VerificationCompleteScreen + * Lifecycle container for verify words flow. + * Manages WalletManager and WordVerifyStateMachine lifecycle. + * Shows either VerifyWordsScreen or VerificationCompleteScreen. */ @Composable fun VerifyWordsContainer( app: AppManager, id: WalletId, - modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var manager by remember { mutableStateOf(null) } - var validator by remember { mutableStateOf(null) } + var stateMachine by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } var verificationComplete by remember { mutableStateOf(false) } var showSecretWordsAlert by remember { mutableStateOf(false) } - // verification state - tracks current word being verified - var wordNumber by remember { mutableIntStateOf(1) } - var questionIndex by remember { mutableIntStateOf(1) } - var possibleWords by remember { mutableStateOf>(emptyList()) } - LaunchedEffect(id) { loading = true try { val walletManager = app.getWalletManager(id) val wordValidator = walletManager.rust.wordValidator() + val sm = WordVerifyStateMachine(wordValidator, 1u) manager = walletManager - validator = wordValidator - - // initialize possible words for first question - possibleWords = wordValidator.possibleWords(1u.toUByte()).map { it.lowercase() } + stateMachine = sm loading = false } catch (e: Exception) { @@ -64,16 +55,15 @@ fun VerifyWordsContainer( DisposableEffect(Unit) { onDispose { - // clean up validator to prevent resource leak - validator?.destroy() - validator = null + stateMachine?.destroy() + stateMachine = null manager = null } } when { loading -> FullPageLoadingView() - manager != null && validator != null -> { + manager != null && stateMachine != null -> { if (verificationComplete) { VerificationCompleteScreen( app = app, @@ -85,41 +75,21 @@ fun VerifyWordsContainer( onBack = { app.popRoute() }, onShowWords = { showSecretWordsAlert = true }, onSkip = { - // skip verification and go to wallet app.resetRoute(Route.SelectedWallet(id)) }, - validator = validator!!, - wordNumber = wordNumber, - questionIndex = questionIndex, - options = possibleWords, + stateMachine = stateMachine!!, snackbarHostState = snackbarHostState, - onCorrectSelected = { word -> - // check if the word is correct - if (!validator!!.isWordCorrect(word, wordNumber.toUByte())) { - return@HotWalletVerifyScreen - } - - // check if verification is complete - if (validator!!.isComplete(wordNumber.toUByte())) { - verificationComplete = true - return@HotWalletVerifyScreen - } - - // advance to next word - wordNumber += 1 - questionIndex += 1 - possibleWords = validator!!.possibleWords(wordNumber.toUByte()).map { it.lowercase() } + onVerificationComplete = { + verificationComplete = true }, ) } } else -> { - // error state FullPageLoadingView() } } - // secret words confirmation dialog if (showSecretWordsAlert) { AlertDialog( onDismissRequest = { showSecretWordsAlert = false }, 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 4747ec1a7..7eabaa9fe 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 @@ -1681,6 +1681,26 @@ external fun uniffi_cove_checksum_method_wordvalidator_is_word_correct( ): Short external fun uniffi_cove_checksum_method_wordvalidator_possible_words( ): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_animation_complete( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_config( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_dwell_complete( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_is_complete( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_possible_words( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_reset_to_word( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_return_complete( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_select_word( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_state( +): Short +external fun uniffi_cove_checksum_method_wordverifystatemachine_word_number( +): Short external fun uniffi_cove_checksum_constructor_ffiapp_new( ): Short external fun uniffi_cove_checksum_constructor_authpin_new( @@ -1779,6 +1799,8 @@ external fun uniffi_cove_checksum_constructor_fingerprint_new( ): Short external fun uniffi_cove_checksum_constructor_wordvalidator_preview( ): Short +external fun uniffi_cove_checksum_constructor_wordverifystatemachine_new( +): Short external fun uniffi_cove_checksum_method_ffireconcile_reconcile( ): Short external fun uniffi_cove_checksum_method_authmanagerreconciler_reconcile( @@ -2762,6 +2784,32 @@ external fun uniffi_cove_fn_method_wordvalidator_is_word_correct(`ptr`: Long,`wo ): Byte external fun uniffi_cove_fn_method_wordvalidator_possible_words(`ptr`: Long,`for`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_fn_clone_wordverifystatemachine(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_free_wordverifystatemachine(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_fn_constructor_wordverifystatemachine_new(`validator`: Long,`startingWordNumber`: Byte,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_method_wordverifystatemachine_animation_complete(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_config(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_dwell_complete(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_is_complete(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_fn_method_wordverifystatemachine_possible_words(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_reset_to_word(`ptr`: Long,`wordNumber`: Byte,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_fn_method_wordverifystatemachine_return_complete(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_select_word(`ptr`: Long,`word`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_state(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_wordverifystatemachine_word_number(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte external fun uniffi_cove_fn_init_callback_vtable_ffireconcile(`vtable`: UniffiVTableCallbackInterfaceFfiReconcile, ): Unit external fun uniffi_cove_fn_init_callback_vtable_authmanagerreconciler(`vtable`: UniffiVTableCallbackInterfaceAuthManagerReconciler, @@ -4146,6 +4194,36 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_wordvalidator_possible_words() != 58432.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_animation_complete() != 38406.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_config() != 39348.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_dwell_complete() != 24167.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_is_complete() != 19976.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_possible_words() != 5700.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_reset_to_word() != 43945.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_return_complete() != 53818.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_select_word() != 45827.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_state() != 57576.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_wordverifystatemachine_word_number() != 36254.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_constructor_ffiapp_new() != 32653.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4293,6 +4371,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_constructor_wordvalidator_preview() != 63940.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_constructor_wordverifystatemachine_new() != 16955.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_ffireconcile_reconcile() != 36143.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -23684,7 +23765,373 @@ open class WordValidator: Disposable, AutoCloseable, WordValidatorInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_wordvalidator(handle, status) + UniffiLib.uniffi_cove_fn_free_wordvalidator(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_wordvalidator(handle, status) + } + } + + override fun `isComplete`(`wordNumber`: kotlin.UByte): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordvalidator_is_complete( + it, + FfiConverterUByte.lower(`wordNumber`),_status) +} + } + ) + } + + + override fun `isWordCorrect`(`word`: kotlin.String, `for`: kotlin.UByte): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordvalidator_is_word_correct( + it, + FfiConverterString.lower(`word`),FfiConverterUByte.lower(`for`),_status) +} + } + ) + } + + + override fun `possibleWords`(`for`: kotlin.UByte): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordvalidator_possible_words( + it, + FfiConverterUByte.lower(`for`),_status) +} + } + ) + } + + + + + + + + + companion object { + fun `preview`(`preview`: kotlin.Boolean, `numberOfWords`: NumberOfBip39Words? = null): WordValidator { + return FfiConverterTypeWordValidator.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_wordvalidator_preview( + + FfiConverterBoolean.lower(`preview`),FfiConverterOptionalTypeNumberOfBip39Words.lower(`numberOfWords`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeWordValidator: FfiConverter { + override fun lower(value: WordValidator): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): WordValidator { + return WordValidator(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): WordValidator { + return lift(buf.getLong()) + } + + override fun allocationSize(value: WordValidator) = 8UL + + override fun write(value: WordValidator, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * State machine for word verification flow + * + * UI sends events (select_word, animation_complete, etc.) and receives + * StateTransition results that tell it what state to render and what + * animations to play. + */ +public interface WordVerifyStateMachineInterface { + + /** + * Animation to target complete - transition to Correct or Incorrect + * + * Returns a transition with the result state and dwell duration. + */ + fun `animationComplete`(): StateTransition + + /** + * Get the animation configuration + */ + fun `config`(): WordVerifyAnimationConfig + + /** + * Dwell time complete - advance word or start return animation + * + * If correct: transitions to None and signals to advance word. + * If incorrect: transitions to Returning state. + */ + fun `dwellComplete`(): StateTransition + + /** + * Check if all words have been verified + */ + fun `isComplete`(): kotlin.Boolean + + /** + * Get possible words for the current word number + */ + fun `possibleWords`(): List + + /** + * Reset to a specific word number (useful for going back) + */ + fun `resetToWord`(`wordNumber`: kotlin.UByte) + + /** + * Return animation complete (after incorrect) - back to None + */ + fun `returnComplete`(): StateTransition + + /** + * User tapped a word - start the checking animation + * + * Returns a transition with Checking state and the animation duration. + * If already animating, returns no-change. + */ + fun `selectWord`(`word`: kotlin.String): StateTransition + + /** + * Get the current state + */ + fun `state`(): WordCheckState + + /** + * Get the current word number being verified (1-indexed) + */ + fun `wordNumber`(): kotlin.UByte + + companion object +} + +/** + * State machine for word verification flow + * + * UI sends events (select_word, animation_complete, etc.) and receives + * StateTransition results that tell it what state to render and what + * animations to play. + */ +open class WordVerifyStateMachine: Disposable, AutoCloseable, WordVerifyStateMachineInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + /** + * Create a new state machine with the given validator + */ + constructor(`validator`: WordValidator, `startingWordNumber`: kotlin.UByte) : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_wordverifystatemachine_new( + + FfiConverterTypeWordValidator.lower(`validator`),FfiConverterUByte.lower(`startingWordNumber`),_status) +} + ) + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_wordverifystatemachine(handle, status) } } } @@ -23697,43 +24144,89 @@ open class WordValidator: Disposable, AutoCloseable, WordValidatorInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_wordvalidator(handle, status) + UniffiLib.uniffi_cove_fn_clone_wordverifystatemachine(handle, status) } } - override fun `isComplete`(`wordNumber`: kotlin.UByte): kotlin.Boolean { - return FfiConverterBoolean.lift( + + /** + * Animation to target complete - transition to Correct or Incorrect + * + * Returns a transition with the result state and dwell duration. + */override fun `animationComplete`(): StateTransition { + return FfiConverterTypeStateTransition.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_wordvalidator_is_complete( + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_animation_complete( it, - FfiConverterUByte.lower(`wordNumber`),_status) + _status) } } ) } - override fun `isWordCorrect`(`word`: kotlin.String, `for`: kotlin.UByte): kotlin.Boolean { + + /** + * Get the animation configuration + */override fun `config`(): WordVerifyAnimationConfig { + return FfiConverterTypeWordVerifyAnimationConfig.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_config( + it, + _status) +} + } + ) + } + + + + /** + * Dwell time complete - advance word or start return animation + * + * If correct: transitions to None and signals to advance word. + * If incorrect: transitions to Returning state. + */override fun `dwellComplete`(): StateTransition { + return FfiConverterTypeStateTransition.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_dwell_complete( + it, + _status) +} + } + ) + } + + + + /** + * Check if all words have been verified + */override fun `isComplete`(): kotlin.Boolean { return FfiConverterBoolean.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_wordvalidator_is_word_correct( + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_is_complete( it, - FfiConverterString.lower(`word`),FfiConverterUByte.lower(`for`),_status) + _status) } } ) } - override fun `possibleWords`(`for`: kotlin.UByte): List { + + /** + * Get possible words for the current word number + */override fun `possibleWords`(): List { return FfiConverterSequenceString.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_wordvalidator_possible_words( + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_possible_words( it, - FfiConverterUByte.lower(`for`),_status) + _status) } } ) @@ -23741,25 +24234,98 @@ open class WordValidator: Disposable, AutoCloseable, WordValidatorInterface - + /** + * Reset to a specific word number (useful for going back) + */override fun `resetToWord`(`wordNumber`: kotlin.UByte) + = + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_reset_to_word( + it, + FfiConverterUByte.lower(`wordNumber`),_status) +} + } + + + /** + * Return animation complete (after incorrect) - back to None + */override fun `returnComplete`(): StateTransition { + return FfiConverterTypeStateTransition.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_return_complete( + it, + _status) +} + } + ) + } + - companion object { - fun `preview`(`preview`: kotlin.Boolean, `numberOfWords`: NumberOfBip39Words? = null): WordValidator { - return FfiConverterTypeWordValidator.lift( + /** + * User tapped a word - start the checking animation + * + * Returns a transition with Checking state and the animation duration. + * If already animating, returns no-change. + */override fun `selectWord`(`word`: kotlin.String): StateTransition { + return FfiConverterTypeStateTransition.lift( + callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_wordvalidator_preview( + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_select_word( + it, + FfiConverterString.lower(`word`),_status) +} + } + ) + } - FfiConverterBoolean.lower(`preview`),FfiConverterOptionalTypeNumberOfBip39Words.lower(`numberOfWords`),_status) + + + /** + * Get the current state + */override fun `state`(): WordCheckState { + return FfiConverterTypeWordCheckState.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_state( + it, + _status) } + } ) } - + + /** + * Get the current word number being verified (1-indexed) + */override fun `wordNumber`(): kotlin.UByte { + return FfiConverterUByte.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_wordverifystatemachine_word_number( + it, + _status) +} } + ) + } + + + + + + + + + + /** + * @suppress + */ + companion object } @@ -23767,22 +24333,22 @@ open class WordValidator: Disposable, AutoCloseable, WordValidatorInterface /** * @suppress */ -public object FfiConverterTypeWordValidator: FfiConverter { - override fun lower(value: WordValidator): Long { +public object FfiConverterTypeWordVerifyStateMachine: FfiConverter { + override fun lower(value: WordVerifyStateMachine): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): WordValidator { - return WordValidator(UniffiWithHandle, value) + override fun lift(value: Long): WordVerifyStateMachine { + return WordVerifyStateMachine(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): WordValidator { + override fun read(buf: ByteBuffer): WordVerifyStateMachine { return lift(buf.getLong()) } - override fun allocationSize(value: WordValidator) = 8UL + override fun allocationSize(value: WordVerifyStateMachine) = 8UL - override fun write(value: WordValidator, buf: ByteBuffer) { + override fun write(value: WordVerifyStateMachine, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -24759,6 +25325,61 @@ public object FfiConverterTypeSendRouteConfirmArgs: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): StateTransition { + return StateTransition( + FfiConverterTypeWordCheckState.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterOptionalUInt.read(buf), + ) + } + + override fun allocationSize(value: StateTransition) = ( + FfiConverterTypeWordCheckState.allocationSize(value.`newState`) + + FfiConverterBoolean.allocationSize(value.`shouldAdvanceWord`) + + FfiConverterOptionalUInt.allocationSize(value.`animationDurationMs`) + ) + + override fun write(value: StateTransition, buf: ByteBuffer) { + FfiConverterTypeWordCheckState.write(value.`newState`, buf) + FfiConverterBoolean.write(value.`shouldAdvanceWord`, buf) + FfiConverterOptionalUInt.write(value.`animationDurationMs`, buf) + } +} + + + data class TapSignerConfirmPinArgs ( var `tapSigner`: TapSigner , @@ -25090,6 +25711,69 @@ public object FfiConverterTypeWalletMetadata: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): WordVerifyAnimationConfig { + return WordVerifyAnimationConfig( + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + ) + } + + override fun allocationSize(value: WordVerifyAnimationConfig) = ( + FfiConverterUInt.allocationSize(value.`moveDurationMsCorrect`) + + FfiConverterUInt.allocationSize(value.`moveDurationMsIncorrect`) + + FfiConverterUInt.allocationSize(value.`dwellDurationMsCorrect`) + + FfiConverterUInt.allocationSize(value.`dwellDurationMsIncorrect`) + ) + + override fun write(value: WordVerifyAnimationConfig, buf: ByteBuffer) { + FfiConverterUInt.write(value.`moveDurationMsCorrect`, buf) + FfiConverterUInt.write(value.`moveDurationMsIncorrect`, buf) + FfiConverterUInt.write(value.`dwellDurationMsCorrect`, buf) + FfiConverterUInt.write(value.`dwellDurationMsIncorrect`, buf) + } +} + + + sealed class AfterPinAction: Disposable { object Derive : AfterPinAction() @@ -40779,6 +41463,169 @@ public object FfiConverterTypeWalletType: FfiConverterRustBuffer { +/** + * The current state of the word verification check + */ +sealed class WordCheckState { + + /** + * No word is being checked + */ + object None : WordCheckState() + + + /** + * User tapped a word, animating to target + */ + data class Checking( + val `word`: kotlin.String) : WordCheckState() + + { + + + companion object + } + + /** + * Word was correct, showing green + */ + data class Correct( + val `word`: kotlin.String) : WordCheckState() + + { + + + companion object + } + + /** + * Word was incorrect, showing red + */ + data class Incorrect( + val `word`: kotlin.String) : WordCheckState() + + { + + + companion object + } + + /** + * Returning to origin after incorrect + */ + data class Returning( + val `word`: kotlin.String) : WordCheckState() + + { + + + companion object + } + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeWordCheckState : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): WordCheckState { + return when(buf.getInt()) { + 1 -> WordCheckState.None + 2 -> WordCheckState.Checking( + FfiConverterString.read(buf), + ) + 3 -> WordCheckState.Correct( + FfiConverterString.read(buf), + ) + 4 -> WordCheckState.Incorrect( + FfiConverterString.read(buf), + ) + 5 -> WordCheckState.Returning( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: WordCheckState) = when(value) { + is WordCheckState.None -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is WordCheckState.Checking -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`word`) + ) + } + is WordCheckState.Correct -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`word`) + ) + } + is WordCheckState.Incorrect -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`word`) + ) + } + is WordCheckState.Returning -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`word`) + ) + } + } + + override fun write(value: WordCheckState, buf: ByteBuffer) { + when(value) { + is WordCheckState.None -> { + buf.putInt(1) + Unit + } + is WordCheckState.Checking -> { + buf.putInt(2) + FfiConverterString.write(value.`word`, buf) + Unit + } + is WordCheckState.Correct -> { + buf.putInt(3) + FfiConverterString.write(value.`word`, buf) + Unit + } + is WordCheckState.Incorrect -> { + buf.putInt(4) + FfiConverterString.write(value.`word`, buf) + Unit + } + is WordCheckState.Returning -> { + buf.putInt(5) + FfiConverterString.write(value.`word`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class XpubException: kotlin.Exception() { diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index fe8d8eef3..0a1b63e0d 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -17,22 +17,23 @@ struct VerifyWordsContainer: View { @State var verificationComplete = false @State private var manager: WalletManager? = nil - @State private var validator: WordValidator? = nil + @State private var stateMachine: WordVerifyStateMachine? = nil func initOnAppear() { do { let manager = try app.getWalletManager(id: id) let validator = try manager.rust.wordValidator() + let sm = WordVerifyStateMachine(validator: validator, startingWordNumber: 1) self.manager = manager - self.validator = validator + self.stateMachine = sm } catch { Log.error("VerifyWords failed to initialize: \(error)") } } @ViewBuilder - func LoadedScreen(manager: WalletManager, validator: WordValidator) -> some View { + func LoadedScreen(manager: WalletManager, stateMachine: WordVerifyStateMachine) -> some View { if verificationComplete { VerificationCompleteScreen(manager: manager) .transition( @@ -43,7 +44,7 @@ struct VerifyWordsContainer: View { } else { VerifyWordsScreen( manager: manager, - validator: validator, + stateMachine: stateMachine, verificationComplete: $verificationComplete ) .transition( @@ -56,10 +57,10 @@ struct VerifyWordsContainer: View { var body: some View { Group { - if let manager, let validator { + if let manager, let stateMachine { if sizeCategory > .extraExtraExtraLarge || isMiniDevice { ScrollView { - LoadedScreen(manager: manager, validator: validator) + LoadedScreen(manager: manager, stateMachine: stateMachine) .frame(minHeight: screenHeight, maxHeight: .infinity) } .background( @@ -68,7 +69,7 @@ struct VerifyWordsContainer: View { ) .adaptiveToolbarStyle() } else { - LoadedScreen(manager: manager, validator: validator) + LoadedScreen(manager: manager, stateMachine: stateMachine) } } else { Text("Loading....") @@ -98,13 +99,13 @@ struct VerifyWordsScreen: View { // args let manager: WalletManager - let validator: WordValidator + let stateMachine: WordVerifyStateMachine @Binding var verificationComplete: Bool // private + @State private var checkState: WordCheckState = .none @State private var wordNumber: Int @State private var possibleWords: [String] - @State private var checkState: CheckState = .none @State private var incorrectGuesses = 0 @Namespace private var namespace @@ -121,14 +122,14 @@ struct VerifyWordsScreen: View { manager.walletMetadata.id } - init(manager: WalletManager, validator: WordValidator, verificationComplete: Binding) { + init(manager: WalletManager, stateMachine: WordVerifyStateMachine, verificationComplete: Binding) { self.manager = manager - self.validator = validator + self.stateMachine = stateMachine _verificationComplete = verificationComplete - wordNumber = 1 - - possibleWords = validator.possibleWords(for: 1) + let wordNum = Int(stateMachine.wordNumber()) + wordNumber = wordNum + possibleWords = stateMachine.possibleWords() } private func DisplayAlert(for alertType: AlertType) -> Alert { @@ -167,15 +168,12 @@ struct VerifyWordsScreen: View { return } - let animation = - if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { - Animation.spring().speed(2.25) - } else { - Animation.spring().speed(1.75) - } + let transition = stateMachine.selectWord(word: word) + + let animation = Animation.spring().speed(2.0) withAnimation(animation) { - checkState = .checking(word) + checkState = transition.newState } completion: { checkWord(word) } @@ -184,7 +182,7 @@ struct VerifyWordsScreen: View { @MainActor func deselectWord(_ animation: Animation = .spring(), completion: @escaping () -> Void = {}) { withAnimation(animation, completionCriteria: .logicallyComplete) { - checkState = .returning + checkState = .returning(word: currentWord ?? "") } completion: { checkState = .none completion() @@ -193,35 +191,49 @@ struct VerifyWordsScreen: View { @MainActor func checkWord(_ word: String) { - if validator.isWordCorrect(word: word, for: UInt8(wordNumber)) { - withAnimation(Animation.spring().speed(3), completionCriteria: .logicallyComplete) { - checkState = .correct(word) - } completion: { - checkState = .none - nextWord() - } - } else { - incorrectGuesses += 1 - withAnimation(Animation.spring().speed(2)) { - checkState = .incorrect(word) - } completion: { - deselectWord(.spring().speed(3)) - } + let transition = stateMachine.animationComplete() + + guard case .correct = transition.newState else { + handleIncorrectWord(transition: transition) + return + } + + withAnimation(Animation.spring().speed(3), completionCriteria: .logicallyComplete) { + checkState = transition.newState + } completion: { + self.handleCorrectWordDwell() } } @MainActor - func nextWord() { - if validator.isComplete(wordNumber: UInt8(wordNumber)) { + private func handleCorrectWordDwell() { + let dwellTransition = stateMachine.dwellComplete() + checkState = .none + + guard dwellTransition.shouldAdvanceWord else { return } + + if stateMachine.isComplete() { withAnimation(.easeInOut(duration: 0.3)) { verificationComplete = true } - return + } else { + withAnimation(.spring().speed(3)) { + wordNumber = Int(stateMachine.wordNumber()) + possibleWords = stateMachine.possibleWords() + } } + } - withAnimation(.spring().speed(3)) { - wordNumber += 1 - possibleWords = validator.possibleWords(for: UInt8(wordNumber)) + @MainActor + private func handleIncorrectWord(transition: StateTransition) { + incorrectGuesses += 1 + withAnimation(Animation.spring().speed(2)) { + checkState = transition.newState + } completion: { + _ = self.stateMachine.dwellComplete() + self.deselectWord(.spring().speed(3)) { + _ = self.stateMachine.returnComplete() + } } } @@ -258,6 +270,15 @@ struct VerifyWordsScreen: View { return [item, item, item, item] } + var currentWord: String? { + switch checkState { + case .checking(let word), .correct(let word), .incorrect(let word), .returning(let word): + return word + case .none: + return nil + } + } + var body: some View { VStack(spacing: 24) { Text("What is word #\(wordNumber)?") @@ -266,7 +287,7 @@ struct VerifyWordsScreen: View { .fontWeight(.semibold) VStack(spacing: 10) { - if let checkingWord = checkState.word { + if let checkingWord = currentWord { Button(action: { guard case .checking = checkState else { return } deselectWord() @@ -287,7 +308,7 @@ struct VerifyWordsScreen: View { .matchedGeometryEffect( id: matchedGeoId(for: checkingWord), in: namespace, - isSource: checkState != .none && checkState != .returning + isSource: checkState != .none && !isReturning ) } else { // take up the same space @@ -311,7 +332,7 @@ struct VerifyWordsScreen: View { .lineLimit(1) .fixedSize(horizontal: false, vertical: true) } - .disabled(isDisabled || checkState.word == word) + .disabled(isDisabled || currentWord == word) .contentShape(Rectangle()) .padding(.horizontal) .padding(.vertical, 12) @@ -320,9 +341,9 @@ struct VerifyWordsScreen: View { .matchedGeometryEffect( id: matchedGeoId(for: word), in: namespace, - isSource: checkState == .none || checkState == .returning + isSource: checkState == .none || isReturning ) - .opacity(checkState.word == word ? 0 : 1) + .opacity(currentWord == word ? 0 : 1) } } .padding(.vertical) @@ -391,38 +412,27 @@ struct VerifyWordsScreen: View { ) .background(Color.midnightBlue) } -} -enum CheckState: Equatable { - case none - case checking(String) - case correct(String) - case incorrect(String) - case returning - - var word: String? { - switch self { - case let .checking(word): - word - case let .correct(word): - word - case let .incorrect(word): - word - case .none, .returning: - nil - } + private var isReturning: Bool { + if case .returning = checkState { return true } + return false } } #Preview { struct Container: View { @State var manager = WalletManager(preview: "preview_only") - @State var validator = WordValidator.preview(preview: true) + @State var stateMachine: WordVerifyStateMachine + + init() { + let validator = WordValidator.preview(preview: true) + _stateMachine = State(initialValue: WordVerifyStateMachine(validator: validator, startingWordNumber: 1)) + } var body: some View { VerifyWordsScreen( manager: manager, - validator: validator, + stateMachine: stateMachine, verificationComplete: Binding.constant(false) ) .environment(AppManager.shared) diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 3b7a980bc..70957ed9c 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -6236,11 +6236,21 @@ public protocol RustAuthManagerProtocol: AnyObject, Sendable { */ func switchToMainMode() + /** + * Validate a new PIN doesn't conflict with existing PINs + */ + func validateNewPin(newPin: String) -> String? + /** * Validate if we have the correct settings to be able to set a decoy or wipe data pin */ func validatePinSettings(pin: String) throws + /** + * Validate a security settings action and return what UI to show + */ + func validateSecurityAction(action: SecuritySettingsAction, unverifiedWalletIds: [WalletId]) -> SecuritySettingsResult + } open class RustAuthManager: RustAuthManagerProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -6481,6 +6491,18 @@ open func switchToMainMode() {try! rustCall() { self.uniffiCloneHandle(),$0 ) } +} + + /** + * Validate a new PIN doesn't conflict with existing PINs + */ +open func validateNewPin(newPin: String) -> String? { + return try! FfiConverterOptionString.lift(try! rustCall() { + uniffi_cove_fn_method_rustauthmanager_validate_new_pin( + self.uniffiCloneHandle(), + FfiConverterString.lower(newPin),$0 + ) +}) } /** @@ -6494,6 +6516,19 @@ open func validatePinSettings(pin: String)throws {try rustCallWithError(FfiCon } } + /** + * Validate a security settings action and return what UI to show + */ +open func validateSecurityAction(action: SecuritySettingsAction, unverifiedWalletIds: [WalletId]) -> SecuritySettingsResult { + return try! FfiConverterTypeSecuritySettingsResult_lift(try! rustCall() { + uniffi_cove_fn_method_rustauthmanager_validate_security_action( + self.uniffiCloneHandle(), + FfiConverterTypeSecuritySettingsAction_lower(action), + FfiConverterSequenceTypeWalletId.lower(unverifiedWalletIds),$0 + ) +}) +} + } @@ -10723,6 +10758,315 @@ public func FfiConverterTypeWordValidator_lower(_ value: WordValidator) -> UInt6 + + +/** + * State machine for word verification flow + * + * UI sends events (select_word, animation_complete, etc.) and receives + * StateTransition results that tell it what state to render and what + * animations to play. + */ +public protocol WordVerifyStateMachineProtocol: AnyObject, Sendable { + + /** + * Animation to target complete - transition to Correct or Incorrect + * + * Returns a transition with the result state and dwell duration. + */ + func animationComplete() -> StateTransition + + /** + * Get the animation configuration + */ + func config() -> WordVerifyAnimationConfig + + /** + * Dwell time complete - advance word or start return animation + * + * If correct: transitions to None and signals to advance word. + * If incorrect: transitions to Returning state. + */ + func dwellComplete() -> StateTransition + + /** + * Check if all words have been verified + */ + func isComplete() -> Bool + + /** + * Get possible words for the current word number + */ + func possibleWords() -> [String] + + /** + * Reset to a specific word number (useful for going back) + */ + func resetToWord(wordNumber: UInt8) + + /** + * Return animation complete (after incorrect) - back to None + */ + func returnComplete() -> StateTransition + + /** + * User tapped a word - start the checking animation + * + * Returns a transition with Checking state and the animation duration. + * If already animating, returns no-change. + */ + func selectWord(word: String) -> StateTransition + + /** + * Get the current state + */ + func state() -> WordCheckState + + /** + * Get the current word number being verified (1-indexed) + */ + func wordNumber() -> UInt8 + +} +/** + * State machine for word verification flow + * + * UI sends events (select_word, animation_complete, etc.) and receives + * StateTransition results that tell it what state to render and what + * animations to play. + */ +open class WordVerifyStateMachine: WordVerifyStateMachineProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_cove_fn_clone_wordverifystatemachine(self.handle, $0) } + } + /** + * Create a new state machine with the given validator + */ +public convenience init(validator: WordValidator, startingWordNumber: UInt8) { + let handle = + try! rustCall() { + uniffi_cove_fn_constructor_wordverifystatemachine_new( + FfiConverterTypeWordValidator_lower(validator), + FfiConverterUInt8.lower(startingWordNumber),$0 + ) +} + self.init(unsafeFromHandle: handle) +} + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_cove_fn_free_wordverifystatemachine(handle, $0) } + } + + + + + /** + * Animation to target complete - transition to Correct or Incorrect + * + * Returns a transition with the result state and dwell duration. + */ +open func animationComplete() -> StateTransition { + return try! FfiConverterTypeStateTransition_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_animation_complete( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Get the animation configuration + */ +open func config() -> WordVerifyAnimationConfig { + return try! FfiConverterTypeWordVerifyAnimationConfig_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_config( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Dwell time complete - advance word or start return animation + * + * If correct: transitions to None and signals to advance word. + * If incorrect: transitions to Returning state. + */ +open func dwellComplete() -> StateTransition { + return try! FfiConverterTypeStateTransition_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_dwell_complete( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Check if all words have been verified + */ +open func isComplete() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_is_complete( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Get possible words for the current word number + */ +open func possibleWords() -> [String] { + return try! FfiConverterSequenceString.lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_possible_words( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Reset to a specific word number (useful for going back) + */ +open func resetToWord(wordNumber: UInt8) {try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_reset_to_word( + self.uniffiCloneHandle(), + FfiConverterUInt8.lower(wordNumber),$0 + ) +} +} + + /** + * Return animation complete (after incorrect) - back to None + */ +open func returnComplete() -> StateTransition { + return try! FfiConverterTypeStateTransition_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_return_complete( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * User tapped a word - start the checking animation + * + * Returns a transition with Checking state and the animation duration. + * If already animating, returns no-change. + */ +open func selectWord(word: String) -> StateTransition { + return try! FfiConverterTypeStateTransition_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_select_word( + self.uniffiCloneHandle(), + FfiConverterString.lower(word),$0 + ) +}) +} + + /** + * Get the current state + */ +open func state() -> WordCheckState { + return try! FfiConverterTypeWordCheckState_lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_state( + self.uniffiCloneHandle(),$0 + ) +}) +} + + /** + * Get the current word number being verified (1-indexed) + */ +open func wordNumber() -> UInt8 { + return try! FfiConverterUInt8.lift(try! rustCall() { + uniffi_cove_fn_method_wordverifystatemachine_word_number( + self.uniffiCloneHandle(),$0 + ) +}) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWordVerifyStateMachine: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = WordVerifyStateMachine + + public static func lift(_ handle: UInt64) throws -> WordVerifyStateMachine { + return WordVerifyStateMachine(unsafeFromHandle: handle) + } + + public static func lower(_ value: WordVerifyStateMachine) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WordVerifyStateMachine { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: WordVerifyStateMachine, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordVerifyStateMachine_lift(_ handle: UInt64) throws -> WordVerifyStateMachine { + return try FfiConverterTypeWordVerifyStateMachine.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordVerifyStateMachine_lower(_ value: WordVerifyStateMachine) -> UInt64 { + return FfiConverterTypeWordVerifyStateMachine.lower(value) +} + + + + public struct AppState { public var router: Router @@ -11939,20 +12283,99 @@ public func FfiConverterTypeSendRouteConfirmArgs_lower(_ value: SendRouteConfirm } -public struct TapSignerConfirmPinArgs { - public var tapSigner: TapSigner - public var startingPin: String - public var newPin: String - public var chainCode: String? - public var action: TapSignerPinAction +/** + * Result of a state transition + */ +public struct StateTransition: Equatable, Hashable { + /** + * The new state after the transition + */ + public var newState: WordCheckState + /** + * Whether the UI should advance to the next word + */ + public var shouldAdvanceWord: Bool + /** + * Suggested animation/delay duration in ms (None if no animation needed) + */ + public var animationDurationMs: UInt32? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(tapSigner: TapSigner, startingPin: String, newPin: String, chainCode: String?, action: TapSignerPinAction) { - self.tapSigner = tapSigner - self.startingPin = startingPin - self.newPin = newPin - self.chainCode = chainCode + public init( + /** + * The new state after the transition + */newState: WordCheckState, + /** + * Whether the UI should advance to the next word + */shouldAdvanceWord: Bool, + /** + * Suggested animation/delay duration in ms (None if no animation needed) + */animationDurationMs: UInt32?) { + self.newState = newState + self.shouldAdvanceWord = shouldAdvanceWord + self.animationDurationMs = animationDurationMs + } + + + + +} + +#if compiler(>=6) +extension StateTransition: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStateTransition: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StateTransition { + return + try StateTransition( + newState: FfiConverterTypeWordCheckState.read(from: &buf), + shouldAdvanceWord: FfiConverterBool.read(from: &buf), + animationDurationMs: FfiConverterOptionUInt32.read(from: &buf) + ) + } + + public static func write(_ value: StateTransition, into buf: inout [UInt8]) { + FfiConverterTypeWordCheckState.write(value.newState, into: &buf) + FfiConverterBool.write(value.shouldAdvanceWord, into: &buf) + FfiConverterOptionUInt32.write(value.animationDurationMs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStateTransition_lift(_ buf: RustBuffer) throws -> StateTransition { + return try FfiConverterTypeStateTransition.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStateTransition_lower(_ value: StateTransition) -> RustBuffer { + return FfiConverterTypeStateTransition.lower(value) +} + + +public struct TapSignerConfirmPinArgs { + public var tapSigner: TapSigner + public var startingPin: String + public var newPin: String + public var chainCode: String? + public var action: TapSignerPinAction + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(tapSigner: TapSigner, startingPin: String, newPin: String, chainCode: String?, action: TapSignerPinAction) { + self.tapSigner = tapSigner + self.startingPin = startingPin + self.newPin = newPin + self.chainCode = chainCode self.action = action } @@ -12274,6 +12697,95 @@ public func FfiConverterTypeWalletMetadata_lower(_ value: WalletMetadata) -> Rus return FfiConverterTypeWalletMetadata.lower(value) } + +/** + * Animation timing configuration + */ +public struct WordVerifyAnimationConfig: Equatable, Hashable { + /** + * Duration (ms) for chip to travel to target when correct + */ + public var moveDurationMsCorrect: UInt32 + /** + * Duration (ms) for chip to travel to target when incorrect + */ + public var moveDurationMsIncorrect: UInt32 + /** + * How long (ms) chip stays at target after arriving (correct) + */ + public var dwellDurationMsCorrect: UInt32 + /** + * How long (ms) chip stays at target after arriving (incorrect) + */ + public var dwellDurationMsIncorrect: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Duration (ms) for chip to travel to target when correct + */moveDurationMsCorrect: UInt32, + /** + * Duration (ms) for chip to travel to target when incorrect + */moveDurationMsIncorrect: UInt32, + /** + * How long (ms) chip stays at target after arriving (correct) + */dwellDurationMsCorrect: UInt32, + /** + * How long (ms) chip stays at target after arriving (incorrect) + */dwellDurationMsIncorrect: UInt32) { + self.moveDurationMsCorrect = moveDurationMsCorrect + self.moveDurationMsIncorrect = moveDurationMsIncorrect + self.dwellDurationMsCorrect = dwellDurationMsCorrect + self.dwellDurationMsIncorrect = dwellDurationMsIncorrect + } + + + + +} + +#if compiler(>=6) +extension WordVerifyAnimationConfig: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWordVerifyAnimationConfig: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WordVerifyAnimationConfig { + return + try WordVerifyAnimationConfig( + moveDurationMsCorrect: FfiConverterUInt32.read(from: &buf), + moveDurationMsIncorrect: FfiConverterUInt32.read(from: &buf), + dwellDurationMsCorrect: FfiConverterUInt32.read(from: &buf), + dwellDurationMsIncorrect: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: WordVerifyAnimationConfig, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.moveDurationMsCorrect, into: &buf) + FfiConverterUInt32.write(value.moveDurationMsIncorrect, into: &buf) + FfiConverterUInt32.write(value.dwellDurationMsCorrect, into: &buf) + FfiConverterUInt32.write(value.dwellDurationMsIncorrect, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordVerifyAnimationConfig_lift(_ buf: RustBuffer) throws -> WordVerifyAnimationConfig { + return try FfiConverterTypeWordVerifyAnimationConfig.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordVerifyAnimationConfig_lower(_ value: WordVerifyAnimationConfig) -> RustBuffer { + return FfiConverterTypeWordVerifyAnimationConfig.lower(value) +} + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -18682,44 +19194,432 @@ public enum ScanResult { } #if compiler(>=6) -extension ScanResult: Sendable {} +extension ScanResult: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeScanResult: FfiConverterRustBuffer { + typealias SwiftType = ScanResult + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScanResult { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .complete(data: try FfiConverterTypeMultiFormat.read(from: &buf), haptic: try FfiConverterTypeHapticFeedback.read(from: &buf) + ) + + case 2: return .inProgress(progress: try FfiConverterTypeScanProgress.read(from: &buf), haptic: try FfiConverterTypeHapticFeedback.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ScanResult, into buf: inout [UInt8]) { + switch value { + + + case let .complete(data,haptic): + writeInt(&buf, Int32(1)) + FfiConverterTypeMultiFormat.write(data, into: &buf) + FfiConverterTypeHapticFeedback.write(haptic, into: &buf) + + + case let .inProgress(progress,haptic): + writeInt(&buf, Int32(2)) + FfiConverterTypeScanProgress.write(progress, into: &buf) + FfiConverterTypeHapticFeedback.write(haptic, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScanResult_lift(_ buf: RustBuffer) throws -> ScanResult { + return try FfiConverterTypeScanResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScanResult_lower(_ value: ScanResult) -> RustBuffer { + return FfiConverterTypeScanResult.lower(value) +} + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum ScanState: Equatable, Hashable { + + case notStarted + case scanning(ScanningInfo + ) + case completed + + + + + +} + +#if compiler(>=6) +extension ScanState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeScanState: FfiConverterRustBuffer { + typealias SwiftType = ScanState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScanState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .notStarted + + case 2: return .scanning(try FfiConverterTypeScanningInfo.read(from: &buf) + ) + + case 3: return .completed + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ScanState, into buf: inout [UInt8]) { + switch value { + + + case .notStarted: + writeInt(&buf, Int32(1)) + + + case let .scanning(v1): + writeInt(&buf, Int32(2)) + FfiConverterTypeScanningInfo.write(v1, into: &buf) + + + case .completed: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScanState_lift(_ buf: RustBuffer) throws -> ScanState { + return try FfiConverterTypeScanState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScanState_lower(_ value: ScanState) -> RustBuffer { + return FfiConverterTypeScanState.lower(value) +} + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum ScannerResponse: Equatable, Hashable { + + case foundAddresses([FoundAddress] + ) + case noneFound + + + + + +} + +#if compiler(>=6) +extension ScannerResponse: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeScannerResponse: FfiConverterRustBuffer { + typealias SwiftType = ScannerResponse + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScannerResponse { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .foundAddresses(try FfiConverterSequenceTypeFoundAddress.read(from: &buf) + ) + + case 2: return .noneFound + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ScannerResponse, into buf: inout [UInt8]) { + switch value { + + + case let .foundAddresses(v1): + writeInt(&buf, Int32(1)) + FfiConverterSequenceTypeFoundAddress.write(v1, into: &buf) + + + case .noneFound: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScannerResponse_lift(_ buf: RustBuffer) throws -> ScannerResponse { + return try FfiConverterTypeScannerResponse.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeScannerResponse_lower(_ value: ScannerResponse) -> RustBuffer { + return FfiConverterTypeScannerResponse.lower(value) +} + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * What alert to show for validation messages + */ + +public enum SecurityAlertState: Equatable, Hashable { + + case unverifiedWallets(walletId: WalletId + ) + case confirmEnableWipeMePin + case confirmDecoyPin + case noteNoFaceIdWhenTrickPins + case noteNoFaceIdWhenWipeMePin + case noteNoFaceIdWhenDecoyPin + case notePinRequired + /** + * Disabling biometric, then show confirm for wipe me PIN + */ + case noteFaceIdDisablingForWipeMePin + /** + * Disabling biometric, then show confirm for decoy PIN + */ + case noteFaceIdDisablingForDecoyPin + case extraSetPinError(message: String + ) + + + + + +} + +#if compiler(>=6) +extension SecurityAlertState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSecurityAlertState: FfiConverterRustBuffer { + typealias SwiftType = SecurityAlertState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SecurityAlertState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .unverifiedWallets(walletId: try FfiConverterTypeWalletId.read(from: &buf) + ) + + case 2: return .confirmEnableWipeMePin + + case 3: return .confirmDecoyPin + + case 4: return .noteNoFaceIdWhenTrickPins + + case 5: return .noteNoFaceIdWhenWipeMePin + + case 6: return .noteNoFaceIdWhenDecoyPin + + case 7: return .notePinRequired + + case 8: return .noteFaceIdDisablingForWipeMePin + + case 9: return .noteFaceIdDisablingForDecoyPin + + case 10: return .extraSetPinError(message: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: SecurityAlertState, into buf: inout [UInt8]) { + switch value { + + + case let .unverifiedWallets(walletId): + writeInt(&buf, Int32(1)) + FfiConverterTypeWalletId.write(walletId, into: &buf) + + + case .confirmEnableWipeMePin: + writeInt(&buf, Int32(2)) + + + case .confirmDecoyPin: + writeInt(&buf, Int32(3)) + + + case .noteNoFaceIdWhenTrickPins: + writeInt(&buf, Int32(4)) + + + case .noteNoFaceIdWhenWipeMePin: + writeInt(&buf, Int32(5)) + + + case .noteNoFaceIdWhenDecoyPin: + writeInt(&buf, Int32(6)) + + + case .notePinRequired: + writeInt(&buf, Int32(7)) + + + case .noteFaceIdDisablingForWipeMePin: + writeInt(&buf, Int32(8)) + + + case .noteFaceIdDisablingForDecoyPin: + writeInt(&buf, Int32(9)) + + + case let .extraSetPinError(message): + writeInt(&buf, Int32(10)) + FfiConverterString.write(message, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSecurityAlertState_lift(_ buf: RustBuffer) throws -> SecurityAlertState { + return try FfiConverterTypeSecurityAlertState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSecurityAlertState_lower(_ value: SecurityAlertState) -> RustBuffer { + return FfiConverterTypeSecurityAlertState.lower(value) +} + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * Action the user wants to take on security settings + */ + +public enum SecuritySettingsAction: Equatable, Hashable { + + case toggleBiometric(enable: Bool + ) + case togglePin(enable: Bool + ) + case toggleWipeDataPin(enable: Bool + ) + case toggleDecoyPin(enable: Bool + ) + case changePin + + + + + +} + +#if compiler(>=6) +extension SecuritySettingsAction: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeScanResult: FfiConverterRustBuffer { - typealias SwiftType = ScanResult +public struct FfiConverterTypeSecuritySettingsAction: FfiConverterRustBuffer { + typealias SwiftType = SecuritySettingsAction - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScanResult { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SecuritySettingsAction { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .complete(data: try FfiConverterTypeMultiFormat.read(from: &buf), haptic: try FfiConverterTypeHapticFeedback.read(from: &buf) + case 1: return .toggleBiometric(enable: try FfiConverterBool.read(from: &buf) ) - case 2: return .inProgress(progress: try FfiConverterTypeScanProgress.read(from: &buf), haptic: try FfiConverterTypeHapticFeedback.read(from: &buf) + case 2: return .togglePin(enable: try FfiConverterBool.read(from: &buf) + ) + + case 3: return .toggleWipeDataPin(enable: try FfiConverterBool.read(from: &buf) + ) + + case 4: return .toggleDecoyPin(enable: try FfiConverterBool.read(from: &buf) ) + case 5: return .changePin + default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ScanResult, into buf: inout [UInt8]) { + public static func write(_ value: SecuritySettingsAction, into buf: inout [UInt8]) { switch value { - case let .complete(data,haptic): + case let .toggleBiometric(enable): writeInt(&buf, Int32(1)) - FfiConverterTypeMultiFormat.write(data, into: &buf) - FfiConverterTypeHapticFeedback.write(haptic, into: &buf) + FfiConverterBool.write(enable, into: &buf) - case let .inProgress(progress,haptic): + case let .togglePin(enable): writeInt(&buf, Int32(2)) - FfiConverterTypeScanProgress.write(progress, into: &buf) - FfiConverterTypeHapticFeedback.write(haptic, into: &buf) + FfiConverterBool.write(enable, into: &buf) + + + case let .toggleWipeDataPin(enable): + writeInt(&buf, Int32(3)) + FfiConverterBool.write(enable, into: &buf) + + + case let .toggleDecoyPin(enable): + writeInt(&buf, Int32(4)) + FfiConverterBool.write(enable, into: &buf) + + case .changePin: + writeInt(&buf, Int32(5)) + } } } @@ -18728,27 +19628,40 @@ public struct FfiConverterTypeScanResult: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScanResult_lift(_ buf: RustBuffer) throws -> ScanResult { - return try FfiConverterTypeScanResult.lift(buf) +public func FfiConverterTypeSecuritySettingsAction_lift(_ buf: RustBuffer) throws -> SecuritySettingsAction { + return try FfiConverterTypeSecuritySettingsAction.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScanResult_lower(_ value: ScanResult) -> RustBuffer { - return FfiConverterTypeScanResult.lower(value) +public func FfiConverterTypeSecuritySettingsAction_lower(_ value: SecuritySettingsAction) -> RustBuffer { + return FfiConverterTypeSecuritySettingsAction.lower(value) } // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * Result of validating a security settings action + */ -public enum ScanState: Equatable, Hashable { +public enum SecuritySettingsResult: Equatable, Hashable { - case notStarted - case scanning(ScanningInfo + /** + * Proceed to show a sheet for PIN entry + */ + case proceedToSheet(SecuritySheetState ) - case completed + /** + * Show an alert dialog + */ + case showAlert(SecurityAlertState + ) + /** + * Decoy mode - just update local UI, don't persist + */ + case decoyModeLocalUpdate @@ -18757,44 +19670,46 @@ public enum ScanState: Equatable, Hashable { } #if compiler(>=6) -extension ScanState: Sendable {} +extension SecuritySettingsResult: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeScanState: FfiConverterRustBuffer { - typealias SwiftType = ScanState +public struct FfiConverterTypeSecuritySettingsResult: FfiConverterRustBuffer { + typealias SwiftType = SecuritySettingsResult - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScanState { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SecuritySettingsResult { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .notStarted + case 1: return .proceedToSheet(try FfiConverterTypeSecuritySheetState.read(from: &buf) + ) - case 2: return .scanning(try FfiConverterTypeScanningInfo.read(from: &buf) + case 2: return .showAlert(try FfiConverterTypeSecurityAlertState.read(from: &buf) ) - case 3: return .completed + case 3: return .decoyModeLocalUpdate default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ScanState, into buf: inout [UInt8]) { + public static func write(_ value: SecuritySettingsResult, into buf: inout [UInt8]) { switch value { - case .notStarted: + case let .proceedToSheet(v1): writeInt(&buf, Int32(1)) + FfiConverterTypeSecuritySheetState.write(v1, into: &buf) + - - case let .scanning(v1): + case let .showAlert(v1): writeInt(&buf, Int32(2)) - FfiConverterTypeScanningInfo.write(v1, into: &buf) + FfiConverterTypeSecurityAlertState.write(v1, into: &buf) - case .completed: + case .decoyModeLocalUpdate: writeInt(&buf, Int32(3)) } @@ -18805,26 +19720,45 @@ public struct FfiConverterTypeScanState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScanState_lift(_ buf: RustBuffer) throws -> ScanState { - return try FfiConverterTypeScanState.lift(buf) +public func FfiConverterTypeSecuritySettingsResult_lift(_ buf: RustBuffer) throws -> SecuritySettingsResult { + return try FfiConverterTypeSecuritySettingsResult.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScanState_lower(_ value: ScanState) -> RustBuffer { - return FfiConverterTypeScanState.lower(value) +public func FfiConverterTypeSecuritySettingsResult_lower(_ value: SecuritySettingsResult) -> RustBuffer { + return FfiConverterTypeSecuritySettingsResult.lower(value) } // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * What sheet to show for PIN entry flows + */ -public enum ScannerResponse: Equatable, Hashable { +public enum SecuritySheetState: Equatable, Hashable { - case foundAddresses([FoundAddress] - ) - case noneFound + case none + case newPin + case removePin + case changePin + case enableBiometric + case disableBiometric + case enableWipeDataPin + case removeWipeDataPin + /** + * Remove wipe data PIN, then enable biometric + */ + case removeWipeDataPinThenEnableBiometric + case enableDecoyPin + case removeDecoyPin + /** + * Remove decoy PIN, then enable biometric + */ + case removeDecoyPinThenEnableBiometric + case removeAllTrickPins @@ -18833,40 +19767,104 @@ public enum ScannerResponse: Equatable, Hashable { } #if compiler(>=6) -extension ScannerResponse: Sendable {} +extension SecuritySheetState: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeScannerResponse: FfiConverterRustBuffer { - typealias SwiftType = ScannerResponse +public struct FfiConverterTypeSecuritySheetState: FfiConverterRustBuffer { + typealias SwiftType = SecuritySheetState - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ScannerResponse { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SecuritySheetState { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .foundAddresses(try FfiConverterSequenceTypeFoundAddress.read(from: &buf) - ) + case 1: return .none - case 2: return .noneFound + case 2: return .newPin + + case 3: return .removePin + + case 4: return .changePin + + case 5: return .enableBiometric + + case 6: return .disableBiometric + + case 7: return .enableWipeDataPin + + case 8: return .removeWipeDataPin + + case 9: return .removeWipeDataPinThenEnableBiometric + + case 10: return .enableDecoyPin + + case 11: return .removeDecoyPin + + case 12: return .removeDecoyPinThenEnableBiometric + + case 13: return .removeAllTrickPins default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ScannerResponse, into buf: inout [UInt8]) { + public static func write(_ value: SecuritySheetState, into buf: inout [UInt8]) { switch value { - case let .foundAddresses(v1): + case .none: writeInt(&buf, Int32(1)) - FfiConverterSequenceTypeFoundAddress.write(v1, into: &buf) - - case .noneFound: + + case .newPin: writeInt(&buf, Int32(2)) + + case .removePin: + writeInt(&buf, Int32(3)) + + + case .changePin: + writeInt(&buf, Int32(4)) + + + case .enableBiometric: + writeInt(&buf, Int32(5)) + + + case .disableBiometric: + writeInt(&buf, Int32(6)) + + + case .enableWipeDataPin: + writeInt(&buf, Int32(7)) + + + case .removeWipeDataPin: + writeInt(&buf, Int32(8)) + + + case .removeWipeDataPinThenEnableBiometric: + writeInt(&buf, Int32(9)) + + + case .enableDecoyPin: + writeInt(&buf, Int32(10)) + + + case .removeDecoyPin: + writeInt(&buf, Int32(11)) + + + case .removeDecoyPinThenEnableBiometric: + writeInt(&buf, Int32(12)) + + + case .removeAllTrickPins: + writeInt(&buf, Int32(13)) + } } } @@ -18875,15 +19873,15 @@ public struct FfiConverterTypeScannerResponse: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScannerResponse_lift(_ buf: RustBuffer) throws -> ScannerResponse { - return try FfiConverterTypeScannerResponse.lift(buf) +public func FfiConverterTypeSecuritySheetState_lift(_ buf: RustBuffer) throws -> SecuritySheetState { + return try FfiConverterTypeSecuritySheetState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScannerResponse_lower(_ value: ScannerResponse) -> RustBuffer { - return FfiConverterTypeScannerResponse.lower(value) +public func FfiConverterTypeSecuritySheetState_lower(_ value: SecuritySheetState) -> RustBuffer { + return FfiConverterTypeSecuritySheetState.lower(value) } @@ -23901,6 +24899,124 @@ public func FfiConverterTypeWalletType_lower(_ value: WalletType) -> RustBuffer } +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * The current state of the word verification check + */ + +public enum WordCheckState: Equatable, Hashable { + + /** + * No word is being checked + */ + case none + /** + * User tapped a word, animating to target + */ + case checking(word: String + ) + /** + * Word was correct, showing green + */ + case correct(word: String + ) + /** + * Word was incorrect, showing red + */ + case incorrect(word: String + ) + /** + * Returning to origin after incorrect + */ + case returning(word: String + ) + + + + + +} + +#if compiler(>=6) +extension WordCheckState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWordCheckState: FfiConverterRustBuffer { + typealias SwiftType = WordCheckState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WordCheckState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .none + + case 2: return .checking(word: try FfiConverterString.read(from: &buf) + ) + + case 3: return .correct(word: try FfiConverterString.read(from: &buf) + ) + + case 4: return .incorrect(word: try FfiConverterString.read(from: &buf) + ) + + case 5: return .returning(word: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: WordCheckState, into buf: inout [UInt8]) { + switch value { + + + case .none: + writeInt(&buf, Int32(1)) + + + case let .checking(word): + writeInt(&buf, Int32(2)) + FfiConverterString.write(word, into: &buf) + + + case let .correct(word): + writeInt(&buf, Int32(3)) + FfiConverterString.write(word, into: &buf) + + + case let .incorrect(word): + writeInt(&buf, Int32(4)) + FfiConverterString.write(word, into: &buf) + + + case let .returning(word): + writeInt(&buf, Int32(5)) + FfiConverterString.write(word, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordCheckState_lift(_ buf: RustBuffer) throws -> WordCheckState { + return try FfiConverterTypeWordCheckState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWordCheckState_lower(_ value: WordCheckState) -> RustBuffer { + return FfiConverterTypeWordCheckState.lower(value) +} + + public enum XpubError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -27415,9 +28531,15 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustauthmanager_switch_to_main_mode() != 36755) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustauthmanager_validate_new_pin() != 2677) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustauthmanager_validate_pin_settings() != 50929) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustauthmanager_validate_security_action() != 4302) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation() != 24764) { return InitializationResult.apiChecksumMismatch } @@ -27964,6 +29086,36 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_wordvalidator_possible_words() != 58432) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_wordverifystatemachine_animation_complete() != 38406) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_config() != 39348) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_dwell_complete() != 24167) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_is_complete() != 19976) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_possible_words() != 5700) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_reset_to_word() != 43945) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_return_complete() != 53818) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_select_word() != 45827) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_state() != 57576) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_wordverifystatemachine_word_number() != 36254) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_constructor_ffiapp_new() != 32653) { return InitializationResult.apiChecksumMismatch } @@ -28108,6 +29260,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_constructor_wordvalidator_preview() != 63940) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_constructor_wordverifystatemachine_new() != 16955) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_ffireconcile_reconcile() != 36143) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 94ae799fc..5a0f4b918 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -35,6 +35,7 @@ mod ur; mod wallet; mod wallet_scanner; mod word_validator; +mod word_verify_state_machine; mod xpub; ::cove_tap_card::uniffi_reexport_scaffolding!(); diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 8ae1d1eee..5041ed864 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -959,7 +959,7 @@ impl RustWalletManager { } Action::ToggleFiatBtcPrimarySecondary => { - let order = [ + const ORDER: &[(FiatOrBtc, Unit); 4] = &[ (FiatOrBtc::Btc, Unit::Btc), (FiatOrBtc::Fiat, Unit::Btc), (FiatOrBtc::Btc, Unit::Sat), @@ -971,14 +971,15 @@ impl RustWalletManager { (md.fiat_or_btc, md.selected_unit) }; - let current_index = order + let current_index = ORDER .iter() .position(|option| option == ¤t) .expect("all options covered"); - let next_index = (current_index + 1) % order.len(); - let (fiat_or_btc, unit) = order[next_index]; + let next_index = (current_index + 1) % ORDER.len(); + let (fiat_or_btc, unit) = ORDER[next_index]; + let mut metadata = self.metadata.write(); metadata.fiat_or_btc = fiat_or_btc; metadata.selected_unit = unit; } diff --git a/rust/src/word_verify_state_machine.rs b/rust/src/word_verify_state_machine.rs new file mode 100644 index 000000000..b856ff094 --- /dev/null +++ b/rust/src/word_verify_state_machine.rs @@ -0,0 +1,355 @@ +//! State machine for word verification flow +//! +//! Provides a shared state machine that both iOS and Android use for +//! the recovery word verification animation flow. + +use std::sync::Arc; + +use parking_lot::RwLock; + +use crate::word_validator::WordValidator; + +/// The current state of the word verification check +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum WordCheckState { + /// No word is being checked + None, + /// User tapped a word, animating to target + Checking { word: String }, + /// Word was correct, showing green + Correct { word: String }, + /// Word was incorrect, showing red + Incorrect { word: String }, + /// Returning to origin after incorrect + Returning { word: String }, +} + + +/// Animation timing configuration +#[derive(Debug, Clone, uniffi::Record)] +pub struct WordVerifyAnimationConfig { + /// Duration (ms) for chip to travel to target when correct + pub move_duration_ms_correct: u32, + /// Duration (ms) for chip to travel to target when incorrect + pub move_duration_ms_incorrect: u32, + /// How long (ms) chip stays at target after arriving (correct) + pub dwell_duration_ms_correct: u32, + /// How long (ms) chip stays at target after arriving (incorrect) + pub dwell_duration_ms_incorrect: u32, +} + +impl Default for WordVerifyAnimationConfig { + fn default() -> Self { + Self { + move_duration_ms_correct: 300, + move_duration_ms_incorrect: 400, + dwell_duration_ms_correct: 1000, + dwell_duration_ms_incorrect: 1000, + } + } +} + +/// Result of a state transition +#[derive(Debug, Clone, uniffi::Record)] +pub struct StateTransition { + /// The new state after the transition + pub new_state: WordCheckState, + /// Whether the UI should advance to the next word + pub should_advance_word: bool, + /// Suggested animation/delay duration in ms (None if no animation needed) + pub animation_duration_ms: Option, +} + +/// Internal mutable state +#[derive(Debug)] +struct Inner { + validator: Arc, + state: WordCheckState, + word_number: u8, + is_correct: Option, + config: WordVerifyAnimationConfig, +} + +/// State machine for word verification flow +/// +/// UI sends events (select_word, animation_complete, etc.) and receives +/// StateTransition results that tell it what state to render and what +/// animations to play. +#[derive(Debug, Clone, uniffi::Object)] +pub struct WordVerifyStateMachine(Arc>); + +#[uniffi::export] +impl WordVerifyStateMachine { + /// Create a new state machine with the given validator + #[uniffi::constructor] + pub fn new(validator: Arc, starting_word_number: u8) -> Self { + Self(Arc::new(RwLock::new(Inner { + validator, + state: WordCheckState::None, + word_number: starting_word_number, + is_correct: None, + config: WordVerifyAnimationConfig::default(), + }))) + } + + /// Get the current state + pub fn state(&self) -> WordCheckState { + self.0.read().state.clone() + } + + /// Get the current word number being verified (1-indexed) + pub fn word_number(&self) -> u8 { + self.0.read().word_number + } + + /// Get possible words for the current word number + pub fn possible_words(&self) -> Vec { + let inner = self.0.read(); + inner.validator.possible_words(inner.word_number) + } + + /// Get the animation configuration + pub fn config(&self) -> WordVerifyAnimationConfig { + self.0.read().config.clone() + } + + /// User tapped a word - start the checking animation + /// + /// Returns a transition with Checking state and the animation duration. + /// If already animating, returns no-change. + pub fn select_word(&self, word: String) -> StateTransition { + let mut inner = self.0.write(); + + if !matches!(inner.state, WordCheckState::None) { + // already animating, ignore + return StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: None, + }; + } + + let is_correct = inner.validator.is_word_correct(word.clone(), inner.word_number); + inner.is_correct = Some(is_correct); + inner.state = WordCheckState::Checking { word }; + + let duration = if is_correct { + inner.config.move_duration_ms_correct + } else { + inner.config.move_duration_ms_incorrect + }; + + StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: Some(duration), + } + } + + /// Animation to target complete - transition to Correct or Incorrect + /// + /// Returns a transition with the result state and dwell duration. + pub fn animation_complete(&self) -> StateTransition { + let mut inner = self.0.write(); + + let word = match &inner.state { + WordCheckState::Checking { word } => word.clone(), + _ => { + return StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: None, + } + } + }; + + let is_correct = inner.is_correct.unwrap_or(false); + + inner.state = if is_correct { + WordCheckState::Correct { word } + } else { + WordCheckState::Incorrect { word } + }; + + let dwell = if is_correct { + inner.config.dwell_duration_ms_correct + } else { + inner.config.dwell_duration_ms_incorrect + }; + + StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: Some(dwell), + } + } + + /// Dwell time complete - advance word or start return animation + /// + /// If correct: transitions to None and signals to advance word. + /// If incorrect: transitions to Returning state. + pub fn dwell_complete(&self) -> StateTransition { + let mut inner = self.0.write(); + + match &inner.state { + WordCheckState::Correct { .. } => { + inner.state = WordCheckState::None; + inner.is_correct = None; + inner.word_number += 1; + + StateTransition { + new_state: inner.state.clone(), + should_advance_word: true, + animation_duration_ms: None, + } + } + WordCheckState::Incorrect { word } => { + inner.state = WordCheckState::Returning { word: word.clone() }; + + StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: Some(inner.config.move_duration_ms_incorrect), + } + } + _ => StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: None, + }, + } + } + + /// Return animation complete (after incorrect) - back to None + pub fn return_complete(&self) -> StateTransition { + let mut inner = self.0.write(); + inner.state = WordCheckState::None; + inner.is_correct = None; + + StateTransition { + new_state: inner.state.clone(), + should_advance_word: false, + animation_duration_ms: None, + } + } + + /// Check if all words have been verified + pub fn is_complete(&self) -> bool { + let inner = self.0.read(); + inner.validator.is_complete(inner.word_number) + } + + /// Reset to a specific word number (useful for going back) + pub fn reset_to_word(&self, word_number: u8) { + let mut inner = self.0.write(); + inner.state = WordCheckState::None; + inner.is_correct = None; + inner.word_number = word_number; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_validator() -> Arc { + Arc::new(WordValidator::preview(true, None)) + } + + #[test] + fn test_initial_state() { + let validator = create_test_validator(); + let sm = WordVerifyStateMachine::new(validator, 1); + + assert_eq!(sm.state(), WordCheckState::None); + assert_eq!(sm.word_number(), 1); + assert!(!sm.is_complete()); + } + + #[test] + fn test_select_correct_word() { + let validator = create_test_validator(); + let sm = WordVerifyStateMachine::new(validator.clone(), 1); + + // get the correct word + let possible = sm.possible_words(); + let correct_word = possible + .iter() + .find(|w| validator.is_word_correct(w.to_string(), 1)) + .unwrap() + .clone(); + + let transition = sm.select_word(correct_word.clone()); + + assert!(matches!(transition.new_state, WordCheckState::Checking { .. })); + assert!(!transition.should_advance_word); + assert!(transition.animation_duration_ms.is_some()); + } + + #[test] + fn test_correct_word_flow() { + let validator = create_test_validator(); + let sm = WordVerifyStateMachine::new(validator.clone(), 1); + + // get the correct word + let possible = sm.possible_words(); + let correct_word = possible + .iter() + .find(|w| validator.is_word_correct(w.to_string(), 1)) + .unwrap() + .clone(); + + // select word + sm.select_word(correct_word); + assert!(matches!(sm.state(), WordCheckState::Checking { .. })); + + // animation complete + let transition = sm.animation_complete(); + assert!(matches!(transition.new_state, WordCheckState::Correct { .. })); + + // dwell complete + let transition = sm.dwell_complete(); + assert!(matches!(transition.new_state, WordCheckState::None)); + assert!(transition.should_advance_word); + assert_eq!(sm.word_number(), 2); + } + + #[test] + fn test_incorrect_word_flow() { + let validator = create_test_validator(); + let sm = WordVerifyStateMachine::new(validator.clone(), 1); + + // select an incorrect word + sm.select_word("wrongword".to_string()); + assert!(matches!(sm.state(), WordCheckState::Checking { .. })); + + // animation complete + let transition = sm.animation_complete(); + assert!(matches!(transition.new_state, WordCheckState::Incorrect { .. })); + + // dwell complete - should transition to returning + let transition = sm.dwell_complete(); + assert!(matches!(transition.new_state, WordCheckState::Returning { .. })); + assert!(!transition.should_advance_word); + + // return complete + let transition = sm.return_complete(); + assert!(matches!(transition.new_state, WordCheckState::None)); + assert_eq!(sm.word_number(), 1); // should not advance + } + + #[test] + fn test_ignore_select_while_animating() { + let validator = create_test_validator(); + let sm = WordVerifyStateMachine::new(validator, 1); + + sm.select_word("word1".to_string()); + let original_state = sm.state(); + + // try to select another word while animating + let transition = sm.select_word("word2".to_string()); + + // should return current state, no change + assert_eq!(transition.new_state, original_state); + } +} From 8c897f63c3817f1541a6d1f4d5176a1e16e982cb Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 08:48:28 -0600 Subject: [PATCH 71/97] Fix merge conflicts and formatting --- .../java/org/bitcoinppl/cove/AppManager.kt | 6 +- .../hot_wallet/HotWalletVerifyScreen.kt | 212 ++++++++++-------- .../VerifyWords/VerifyWordsScreen.swift | 8 +- rust/src/word_verify_state_machine.rs | 17 +- 4 files changed, 127 insertions(+), 116 deletions(-) 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 1d0c27640..a1ac73af1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -576,10 +576,10 @@ class AppManager private constructor() : FfiReconcile { isLoading = false } } - } - is AppStateReconcileMessage.WalletsChanged -> { - wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() } + is AppStateReconcileMessage.WalletsChanged -> { + wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() } + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index 52a75efc1..729b6408d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -107,15 +107,17 @@ fun HotWalletVerifyScreen( val config = remember { stateMachine.config() } // colors derived from state - no recomputation bug possible - val overlayBg = when (checkState) { - is WordCheckState.Correct -> CoveColor.SuccessGreen - is WordCheckState.Incorrect -> CoveColor.ErrorRed - else -> CoveColor.btnPrimary - } - val overlayText = when (checkState) { - is WordCheckState.Correct, is WordCheckState.Incorrect -> Color.White - else -> CoveColor.midnightBlue - } + val overlayBg = + when (checkState) { + is WordCheckState.Correct -> CoveColor.SuccessGreen + is WordCheckState.Incorrect -> CoveColor.ErrorRed + else -> CoveColor.btnPrimary + } + val overlayText = + when (checkState) { + is WordCheckState.Correct, is WordCheckState.Incorrect -> Color.White + else -> CoveColor.midnightBlue + } // handle state machine transitions LaunchedEffect(checkState) { @@ -212,12 +214,13 @@ fun HotWalletVerifyScreen( containerColor = CoveColor.midnightBlue, topBar = { CenterAlignedTopAppBar( - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - actionIconContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + actionIconContentColor = Color.White, + navigationIconContentColor = Color.White, + ), title = { Text( stringResource(R.string.title_verify_recovery_words), @@ -240,34 +243,38 @@ fun HotWalletVerifyScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .onGloballyPositioned { coords -> - val pos = coords.positionInRoot() - rootOffset = Offset(pos.x, pos.y) - }, + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .onGloballyPositioned { coords -> + val pos = coords.positionInRoot() + rootOffset = Offset(pos.x, pos.y) + }, ) { Image( painter = painterResource(id = R.drawable.image_chain_code_pattern_horizontal), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxHeight() - .align(Alignment.TopCenter), + modifier = + Modifier + .fillMaxHeight() + .align(Alignment.TopCenter), ) Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(vertical = 20.dp), + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -292,16 +299,18 @@ fun HotWalletVerifyScreen( contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .width(cellWidth) - .height(chipHeight) - .onGloballyPositioned { coordinates -> - val pos = coordinates.positionInRoot() - targetPosition = Offset( - pos.x - rootOffset.x, - pos.y - rootOffset.y, - ) - }, + modifier = + Modifier + .width(cellWidth) + .height(chipHeight) + .onGloballyPositioned { coordinates -> + val pos = coordinates.positionInRoot() + targetPosition = + Offset( + pos.x - rootOffset.x, + pos.y - rootOffset.y, + ) + }, ) { } } } @@ -332,22 +341,24 @@ fun HotWalletVerifyScreen( ) { rowItems.forEach { word -> Box(modifier = Modifier.weight(1f)) { - val isAnimating = checkState.let { - when (it) { - is WordCheckState.Checking -> it.word == word - is WordCheckState.Correct -> it.word == word - is WordCheckState.Incorrect -> it.word == word - is WordCheckState.Returning -> it.word == word - WordCheckState.None -> false + val isAnimating = + checkState.let { + when (it) { + is WordCheckState.Checking -> it.word == word + is WordCheckState.Correct -> it.word == word + is WordCheckState.Incorrect -> it.word == word + is WordCheckState.Returning -> it.word == word + WordCheckState.None -> false + } } - } if (isAnimating) { // placeholder while animating Box( - modifier = Modifier - .fillMaxWidth() - .height(46.dp), + modifier = + Modifier + .fillMaxWidth() + .height(46.dp), ) { } } else { OptionChip( @@ -361,10 +372,11 @@ fun HotWalletVerifyScreen( }, onPositionCaptured = { position -> wordPositions = wordPositions + ( - word to Offset( - position.x - rootOffset.x, - position.y - rootOffset.y, - ) + word to + Offset( + position.x - rootOffset.x, + position.y - rootOffset.y, + ) ) }, ) @@ -382,9 +394,10 @@ fun HotWalletVerifyScreen( Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { DashDotsIndicator( @@ -413,10 +426,11 @@ fun HotWalletVerifyScreen( Button( onClick = onShowWords, shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = CoveColor.btnPrimary, - contentColor = CoveColor.midnightBlue, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = CoveColor.btnPrimary, + contentColor = CoveColor.midnightBlue, + ), contentPadding = PaddingValues(vertical = 20.dp, horizontal = 10.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -435,10 +449,11 @@ fun HotWalletVerifyScreen( fontWeight = FontWeight.Medium, fontSize = 12.sp, textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - .clickable { showSkipAlert = true }, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clickable { showSkipAlert = true }, ) } } @@ -472,27 +487,28 @@ fun HotWalletVerifyScreen( } // animated overlay chip - val currentWord = when (val state = checkState) { - is WordCheckState.Checking -> state.word - is WordCheckState.Correct -> state.word - is WordCheckState.Incorrect -> state.word - is WordCheckState.Returning -> state.word - WordCheckState.None -> null - } + val currentWord = + when (val state = checkState) { + is WordCheckState.Checking -> state.word + is WordCheckState.Correct -> state.word + is WordCheckState.Incorrect -> state.word + is WordCheckState.Returning -> state.word + WordCheckState.None -> null + } if (currentWord != null) { Box( - modifier = Modifier - .offset { - IntOffset( - animationX.value.roundToInt(), - animationY.value.roundToInt(), - ) - } - .width(actualChipWidth) - .height(chipHeight) - .background(overlayBg, RoundedCornerShape(14.dp)) - .zIndex(10f), + modifier = + Modifier + .offset { + IntOffset( + animationX.value.roundToInt(), + animationY.value.roundToInt(), + ) + }.width(actualChipWidth) + .height(chipHeight) + .background(overlayBg, RoundedCornerShape(14.dp)) + .zIndex(10f), contentAlignment = Alignment.Center, ) { Text( @@ -520,20 +536,22 @@ private fun OptionChip( val textColor = if (selected) CoveColor.midnightBlue else CoveColor.midnightBlue Box( - modifier = modifier - .fillMaxWidth() - .height(46.dp), + modifier = + modifier + .fillMaxWidth() + .height(46.dp), contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .fillMaxSize() - .clip(shape) - .background(bg, shape) - .clickable { onClick() } - .onGloballyPositioned { coordinates -> - onPositionCaptured(coordinates.positionInRoot()) - }, + modifier = + Modifier + .fillMaxSize() + .clip(shape) + .background(bg, shape) + .clickable { onClick() } + .onGloballyPositioned { coordinates -> + onPositionCaptured(coordinates.positionInRoot()) + }, contentAlignment = Alignment.Center, ) { AutoSizeText( diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift index 0a1b63e0d..83f325ab1 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/VerifyWords/VerifyWordsScreen.swift @@ -190,7 +190,7 @@ struct VerifyWordsScreen: View { } @MainActor - func checkWord(_ word: String) { + func checkWord(_: String) { let transition = stateMachine.animationComplete() guard case .correct = transition.newState else { @@ -272,10 +272,10 @@ struct VerifyWordsScreen: View { var currentWord: String? { switch checkState { - case .checking(let word), .correct(let word), .incorrect(let word), .returning(let word): - return word + case let .checking(word), let .correct(word), let .incorrect(word), let .returning(word): + word case .none: - return nil + nil } } diff --git a/rust/src/word_verify_state_machine.rs b/rust/src/word_verify_state_machine.rs index b856ff094..0a330e331 100644 --- a/rust/src/word_verify_state_machine.rs +++ b/rust/src/word_verify_state_machine.rs @@ -24,7 +24,6 @@ pub enum WordCheckState { Returning { word: String }, } - /// Animation timing configuration #[derive(Debug, Clone, uniffi::Record)] pub struct WordVerifyAnimationConfig { @@ -159,7 +158,7 @@ impl WordVerifyStateMachine { new_state: inner.state.clone(), should_advance_word: false, animation_duration_ms: None, - } + }; } }; @@ -273,11 +272,8 @@ mod tests { // get the correct word let possible = sm.possible_words(); - let correct_word = possible - .iter() - .find(|w| validator.is_word_correct(w.to_string(), 1)) - .unwrap() - .clone(); + let correct_word = + possible.iter().find(|w| validator.is_word_correct(w.to_string(), 1)).unwrap().clone(); let transition = sm.select_word(correct_word.clone()); @@ -293,11 +289,8 @@ mod tests { // get the correct word let possible = sm.possible_words(); - let correct_word = possible - .iter() - .find(|w| validator.is_word_correct(w.to_string(), 1)) - .unwrap() - .clone(); + let correct_word = + possible.iter().find(|w| validator.is_word_correct(w.to_string(), 1)).unwrap().clone(); // select word sm.select_word(correct_word); From bc7b56522f1ddd8976b1e2b1605bf3e3528d0f47 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 15:43:20 -0600 Subject: [PATCH 72/97] Remove isPreview conditionals and preview code from Android views TransactionDetailsScreen: Remove all @Preview functions, preview helper classes, and isPreview conditionals. Views now require non-null managers matching iOS pattern. WalletSettingsScreen: Remove WalletColorSelectorPreview and previewWalletColors. Simplify color loading with try-catch fallback. --- .../cove/settings/WalletSettingsScreen.kt | 43 +--- .../TransactionDetailsScreen.kt | 225 +++--------------- 2 files changed, 43 insertions(+), 225 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt index a0ee8adc2..8b2be3783 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt @@ -46,10 +46,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bitcoinppl.cove.AppManager @@ -306,27 +304,6 @@ fun WalletSettingsScreen( } } -@Preview -@Composable -fun WalletColorSelectorPreview() { - WalletColorSelector(WalletColor.Orange) -} - -// static color list for compose previews to avoid FFI calls in IDE -private val previewWalletColors = - listOf( - WalletColor.WBeige, - WalletColor.WPastelBlue, - WalletColor.WPastelNavy, - WalletColor.WPastelRed, - WalletColor.WPastelYellow, - WalletColor.WPastelTeal, - WalletColor.Blue, - WalletColor.Green, - WalletColor.Orange, - WalletColor.Purple, - ) - @Composable private fun WalletColorSelector( selectedWalletColor: WalletColor, @@ -336,20 +313,14 @@ private fun WalletColorSelector( mutableStateOf(selectedWalletColor) } - val isInPreview = LocalInspectionMode.current - val availableColors = - remember(isInPreview) { - if (isInPreview) { - previewWalletColors - } else { - try { - defaultWalletColors() - } catch (e: Throwable) { - Log.e("WalletSettingsScreen", "failed to load default wallet colors", e) - previewWalletColors - } - } + val availableColors = remember { + try { + defaultWalletColors() + } catch (e: Throwable) { + Log.e("WalletSettingsScreen", "failed to load default wallet colors", e) + emptyList() } + } Column( Modifier diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index c43b62708..5b7f0f25f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -70,12 +70,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode 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.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -98,14 +96,9 @@ import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletMetadata -import org.bitcoinppl.cove_core.types.Address -import org.bitcoinppl.cove_core.types.Amount import org.bitcoinppl.cove_core.types.BitcoinUnit import org.bitcoinppl.cove_core.types.FfiColorScheme -import org.bitcoinppl.cove_core.types.NoHandle import org.bitcoinppl.cove_core.types.TransactionDirection -import org.bitcoinppl.cove_core.types.TxId -import org.bitcoinppl.cove_core.NoHandle as CoreNoHandle private const val INITIAL_DELAY_MS = 2000L private const val FREQUENT_POLL_INTERVAL_MS = 30000L @@ -113,122 +106,6 @@ private const val NORMAL_POLL_INTERVAL_MS = 60000L private const val MAX_POLL_ERRORS = 10 private const val CONFIRMATIONS_THRESHOLD = 3 -@Preview(name = "Confirmed Received", showBackground = true) -@Composable -private fun TransactionDetailsConfirmedReceivedPreview() { - TransactionDetailsPreviewContent(previewDetails(isSent = false, isConfirmed = true)) -} - -@Preview(name = "Confirmed Sent", showBackground = true) -@Composable -private fun TransactionDetailsConfirmedSentPreview() { - TransactionDetailsPreviewContent(previewDetails(isSent = true, isConfirmed = true)) -} - -@Preview(name = "Pending Received", showBackground = true) -@Composable -private fun TransactionDetailsPendingReceivedPreview() { - TransactionDetailsPreviewContent(previewDetails(isSent = false, isConfirmed = false)) -} - -@Preview(name = "Pending Sent", showBackground = true) -@Composable -private fun TransactionDetailsPendingSentPreview() { - TransactionDetailsPreviewContent(previewDetails(isSent = true, isConfirmed = false)) -} - -@Composable -private fun TransactionDetailsPreviewContent(details: TransactionDetails) { - TransactionDetailsScreen( - app = null, - manager = null, - details = details, - isPreviewMode = true, - ) -} - -private fun previewDetails(isSent: Boolean, isConfirmed: Boolean): TransactionDetails = - PreviewTransactionDetails( - isSentTx = isSent, - isConfirmedTx = isConfirmed, - formattedDate = if (isConfirmed) "Nov 25, 2025 at 3:45 PM" else "", - address = "bc1q 4kmx 9uqq 0flm f232 0ntz 3apc z4en v97t", - amountPrimary = if (isSent) "-25,555 sats" else "+25,555 sats", - amountFiat = if (isSent) "-$28.88" else "$28.88", - feePrimary = if (isSent) "98 sats" else null, - feeFiat = if (isSent) "$0.02" else null, - sentSansFeePrimary = if (isSent) "25,457 sats" else null, - sentSansFeeFiat = if (isSent) "$28.86" else null, - totalSpentPrimary = if (isSent) "25,653 sats" else null, - ) - -// Lightweight preview-safe TransactionDetails that avoids loading native libs. -private class PreviewTransactionDetails( - private val isSentTx: Boolean, - private val isConfirmedTx: Boolean, - private val formattedDate: String, - private val address: String, - private val amountPrimary: String, - private val amountFiat: String, - private val feePrimary: String?, - private val feeFiat: String?, - private val sentSansFeePrimary: String?, - private val sentSansFeeFiat: String?, - private val totalSpentPrimary: String? = null, - private val url: String = "https://mempool.space/tx/preview", - private val txIdValue: String = "preview-tx-id", -) : TransactionDetails(CoreNoHandle) { - override fun isSent(): Boolean = isSentTx - - override fun isReceived(): Boolean = !isSentTx - - override fun isConfirmed(): Boolean = isConfirmedTx - - override fun confirmationDateTime(): String? = formattedDate - - override fun transactionUrl(): String = url - - override fun txId(): TxId = PreviewTxId(txIdValue) - - override fun amountFmt(unit: BitcoinUnit): String = amountPrimary - - override suspend fun amountFiatFmt(): String = amountFiat - - override fun feeFmt(unit: BitcoinUnit): String? = feePrimary - - override suspend fun feeFiatFmt(): String = feeFiat ?: "" - - override fun sentSansFeeFmt(unit: BitcoinUnit): String? = sentSansFeePrimary - - override suspend fun sentSansFeeFiatFmt(): String = sentSansFeeFiat ?: "" - - override fun amount(): Amount = PreviewAmount() - - override fun addressSpacedOut(): String = address - - override fun address(): Address = PreviewAddress(address.replace(" ", "")) - - override fun blockNumberFmt(): String? = "123456" - - override fun blockNumber(): UInt? = 123456u - - override fun transactionLabel(): String? = null -} - -private class PreviewAmount : Amount(NoHandle) - -private class PreviewAddress( - private val value: String, -) : Address(NoHandle) { - override fun string(): String = value -} - -private class PreviewTxId( - private val value: String = "preview", -) : TxId(NoHandle) { - override fun toString(): String = value -} - /** * transaction details screen - now using manager-based pattern * ported from iOS TransactionDetailsView.swift @@ -236,15 +113,12 @@ private class PreviewTxId( @OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionDetailsScreen( - app: AppManager?, - manager: WalletManager?, + app: AppManager, + manager: WalletManager, details: TransactionDetails, - isPreviewMode: Boolean = false, ) { val context = LocalContext.current - val inspectionMode = LocalInspectionMode.current - val isPreview = isPreviewMode || inspectionMode || app == null || manager == null - val metadata = manager?.walletMetadata + val metadata = manager.walletMetadata ?: return // state for confirmation polling var numberOfConfirmations by remember { mutableStateOf(null) } @@ -279,9 +153,7 @@ fun TransactionDetailsScreen( } // poll for confirmations if not fully confirmed - LaunchedEffect(transactionDetails.txId(), isPreview) { - if (isPreview || manager == null) return@LaunchedEffect - + LaunchedEffect(transactionDetails.txId()) { if (!transactionDetails.isConfirmed()) { delay(INITIAL_DELAY_MS) } @@ -348,50 +220,32 @@ fun TransactionDetailsScreen( val isConfirmed = transactionDetails.isConfirmed() // header icon presenter for dynamic colors - val presenter = remember(isPreview) { if (isPreview) null else HeaderIconPresenter() } + 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() - ?: if (isSent) Color(0xFF0F0F12) else CoveColor.TransactionReceived - val iconColor = presenter?.iconColor(txState, direction, colorScheme, confirmationCount)?.toColor() ?: Color.White + val circleColor = presenter.backgroundColor(txState, direction, colorScheme, confirmationCount).toColor() + val iconColor = presenter.iconColor(txState, direction, colorScheme, confirmationCount).toColor() - // get ring colors; fall back to static values in preview (matching iOS opacities) + // 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 = - presenter?.let { - listOf( - it - .ringColor(txState, colorScheme, direction, confirmationCount, 1L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[0]) }, - it - .ringColor(txState, colorScheme, direction, confirmationCount, 2L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[1]) }, - it - .ringColor(txState, colorScheme, direction, confirmationCount, 3L) - .toColor() - .let { color -> color.copy(alpha = color.alpha * ringOpacities[2]) }, - ) - } - ?: if (isDark) { - listOf( - Color.White.copy(alpha = 0.88f), - Color.White.copy(alpha = 0.66f), - Color.White.copy(alpha = 0.33f), - ) - } else { - listOf( - Color.Black.copy(alpha = 0.44f), - Color.Black.copy(alpha = 0.24f), - Color.Black.copy(alpha = 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( @@ -427,12 +281,7 @@ fun TransactionDetailsScreen( } // format amounts - val txAmountPrimary = - if (manager != null) { - manager.rust.displayAmount(amount = transactionDetails.amount()) - } else { - transactionDetails.amountFmt(metadata?.selectedUnit ?: BitcoinUnit.SAT) - } + val txAmountPrimary = manager.rust.displayAmount(amount = transactionDetails.amount()) val txAmountSecondary by androidx.compose.runtime.produceState(initialValue = "---") { value = try { @@ -443,7 +292,7 @@ fun TransactionDetailsScreen( } // details expanded from metadata - val isExpanded = metadata?.detailsExpanded ?: false + val isExpanded = metadata.detailsExpanded Scaffold( containerColor = bg, @@ -458,7 +307,7 @@ fun TransactionDetailsScreen( ), title = { Text("") }, navigationIcon = { - IconButton(onClick = { app?.popRoute() }) { + IconButton(onClick = { app.popRoute() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, @@ -526,14 +375,12 @@ fun TransactionDetailsScreen( Spacer(Modifier.height(4.dp)) - if (!isPreview && manager != null) { - TransactionLabelView( - transactionDetails = transactionDetails, - manager = manager, - secondaryColor = sub, - snackbarHostState = snackbarHostState, - ) - } + TransactionLabelView( + transactionDetails = transactionDetails, + manager = manager, + secondaryColor = sub, + snackbarHostState = snackbarHostState, + ) Spacer(Modifier.height(24.dp)) @@ -704,7 +551,7 @@ fun TransactionDetailsScreen( TextButton( onClick = { - manager?.dispatch(WalletManagerAction.ToggleDetailsExpanded) + manager.dispatch(WalletManagerAction.ToggleDetailsExpanded) }, modifier = Modifier @@ -732,7 +579,7 @@ private fun TransactionDetailsWidget( feeFiatFmt: String, sentSansFeeFiatFmt: String, totalSpentFiatFmt: String, - metadata: WalletMetadata?, + metadata: WalletMetadata, ) { val dividerColor = MaterialTheme.colorScheme.outlineVariant val sub = MaterialTheme.colorScheme.onSurfaceVariant @@ -864,7 +711,7 @@ private fun TransactionDetailsWidget( if (isSent) { DetailsWidget( label = stringResource(R.string.label_network_fee), - primary = transactionDetails.feeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + primary = transactionDetails.feeFmt(unit = metadata.selectedUnit), secondary = feeFiatFmt, showInfoIcon = true, onInfoClick = { /* TODO: show fee info */ }, @@ -873,7 +720,7 @@ private fun TransactionDetailsWidget( DetailsWidget( label = stringResource(R.string.label_recipient_receives), - primary = transactionDetails.sentSansFeeFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + primary = transactionDetails.sentSansFeeFmt(unit = metadata.selectedUnit), secondary = sentSansFeeFiatFmt, ) Spacer(Modifier.height(24.dp)) @@ -889,7 +736,7 @@ private fun TransactionDetailsWidget( DetailsWidget( label = stringResource(R.string.label_total_spent), - primary = transactionDetails.amountFmt(unit = metadata?.selectedUnit ?: BitcoinUnit.SAT), + primary = transactionDetails.amountFmt(unit = metadata.selectedUnit), secondary = totalSpentFiatFmt, isTotal = true, ) From c54ee3d63a4afd4ef658b7be1250cd470d0da21a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 16:19:53 -0600 Subject: [PATCH 73/97] Migrate Android QR scanning to Rust QrScanner state machine Port iOS QR scanning pattern to Android using the Rust QrScanner introduced in 2aabd4a5: - Add HapticFeedbackExt.kt for triggering haptic feedback from Rust enum - Rewrite QrCodeScanView to use Rust QrScanner with MultiFormat callback - Simplify QrCodeImportScreen by using QrCodeScanView (~320 lines removed) - Simplify HotWalletImportScreen QR sheet (~280 lines removed) - Update all consumer files to handle MultiFormat types: SendFlowContainer, ColdWalletQrScanScreen, CoinControlSetAmountScreen, MainActivity, HardwareExportScreen - Add UR format support (previously missing on Android) This removes ~600 lines of duplicate camera/state machine code and brings Android QR scanning to parity with iOS. --- .../org/bitcoinppl/cove/HapticFeedbackExt.kt | 60 ++ .../bitcoinppl/cove/HardwareExportScreen.kt | 19 +- .../java/org/bitcoinppl/cove/MainActivity.kt | 35 +- .../org/bitcoinppl/cove/QrCodeScanView.kt | 577 +++++++----------- .../org/bitcoinppl/cove/SendFlowContainer.kt | 20 +- .../cold_wallet/ColdWalletQrScanScreen.kt | 115 ++-- .../cold_wallet/QrCodeImportScreen.kt | 456 ++------------ .../hot_wallet/HotWalletImportScreen.kt | 357 +---------- .../cove/send/CoinControlSetAmountScreen.kt | 24 +- .../cove/settings/WalletSettingsScreen.kt | 15 +- .../cove/sidebar/SidebarContainer.kt | 1 + .../bitcoinppl/cove/sidebar/SidebarView.kt | 4 + .../TransactionDetailsScreen.kt | 30 +- 13 files changed, 520 insertions(+), 1193 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/HapticFeedbackExt.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HapticFeedbackExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/HapticFeedbackExt.kt new file mode 100644 index 000000000..3591206d8 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/HapticFeedbackExt.kt @@ -0,0 +1,60 @@ +package org.bitcoinppl.cove + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import org.bitcoinppl.cove_core.HapticFeedback + +fun HapticFeedback.trigger(context: Context) { + when (this) { + HapticFeedback.PROGRESS -> triggerLightVibration(context) + HapticFeedback.SUCCESS -> triggerSuccessVibration(context) + HapticFeedback.NONE -> { /* no-op */ } + } +} + +private fun triggerLightVibration(context: Context) { + try { + val vibrator = getVibrator(context) + vibrator?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + it.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + it.vibrate(50) + } + } + } catch (e: Exception) { + Log.w("HapticFeedback", "Failed to trigger light vibration", e) + } +} + +private fun triggerSuccessVibration(context: Context) { + try { + val vibrator = getVibrator(context) + vibrator?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + it.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + it.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + it.vibrate(100) + } + } + } catch (e: Exception) { + Log.w("HapticFeedback", "Failed to trigger success vibration", e) + } +} + +private fun getVibrator(context: Context): Vibrator? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt index e03b4c981..a94ea64d3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/HardwareExportScreen.kt @@ -628,22 +628,9 @@ fun HardwareExportScreen( // fullscreen QR scanner if (showQrScanner) { QrCodeScanView( - onScanned = { stringOrData -> - try { - val multiFormat = stringOrDataTryIntoMultiFormat(stringOrData) - app.handleMultiFormat(multiFormat) - showQrScanner = false - } catch (e: Exception) { - Log.e("HardwareExportScreen", "Error scanning QR code: $e") - showQrScanner = false - app.alertState = - TaggedItem( - AppAlertState.General( - title = "Scan Failed", - message = e.message ?: "Failed to scan QR code", - ), - ) - } + onScanned = { multiFormat -> + showQrScanner = false + app.handleMultiFormat(multiFormat) }, onDismiss = { showQrScanner = false }, app = app, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 80363ecb5..44256852d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -1,5 +1,8 @@ package org.bitcoinppl.cove +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle @@ -43,9 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import org.bitcoinppl.cove.navigation.CoveNavDisplay @@ -57,7 +58,6 @@ import org.bitcoinppl.cove.views.LockView import org.bitcoinppl.cove_core.AfterPinAction import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.TapSignerRoute -import org.bitcoinppl.cove_core.stringOrDataTryIntoMultiFormat import org.bitcoinppl.cove_core.types.ColorSchemeSelection class MainActivity : FragmentActivity() { @@ -268,18 +268,9 @@ private fun SheetContent( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { QrCodeScanView( - onScanned = { stringOrData -> + onScanned = { multiFormat -> app.sheetState = null - try { - val multiFormat = stringOrDataTryIntoMultiFormat(stringOrData) - app.handleMultiFormat(multiFormat) - } catch (e: Exception) { - Log.e("MainActivity", "Failed to parse QR code: ${e.message}", e) - app.alertState = - TaggedItem( - AppAlertState.InvalidFormat(e.message ?: "Unknown error"), - ) - } + app.handleMultiFormat(multiFormat) }, onDismiss = onDismiss, app = app, @@ -336,6 +327,13 @@ private fun GlobalAlertDialog( app: AppManager, onDismiss: () -> Unit, ) { + val context = LocalContext.current + + fun copyToClipboard(text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("address", text)) + } + when (val state = alert.item) { is AppAlertState.DuplicateWallet -> { AlertDialog( @@ -379,14 +377,13 @@ private fun GlobalAlertDialog( } is AppAlertState.AddressWrongNetwork -> { - val clipboardManager = LocalClipboardManager.current AlertDialog( onDismissRequest = onDismiss, title = { Text(state.title()) }, text = { Text(state.message()) }, confirmButton = { TextButton(onClick = { - clipboardManager.setText(AnnotatedString(state.address.string())) + copyToClipboard(state.address.string()) onDismiss() }) { Text("Copy Address") } }, @@ -397,14 +394,13 @@ private fun GlobalAlertDialog( } is AppAlertState.FoundAddress -> { - val clipboardManager = LocalClipboardManager.current AlertDialog( onDismissRequest = onDismiss, title = { Text(state.title()) }, text = { Text(state.message()) }, confirmButton = { TextButton(onClick = { - clipboardManager.setText(AnnotatedString(state.address.string())) + copyToClipboard(state.address.string()) onDismiss() }) { Text("Copy Address") } }, @@ -415,14 +411,13 @@ private fun GlobalAlertDialog( } is AppAlertState.NoWalletSelected -> { - val clipboardManager = LocalClipboardManager.current AlertDialog( onDismissRequest = onDismiss, title = { Text(state.title()) }, text = { Text(state.message()) }, confirmButton = { TextButton(onClick = { - clipboardManager.setText(AnnotatedString(state.address.string())) + copyToClipboard(state.address.string()) onDismiss() }) { Text("Copy Address") } }, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt index 6eb035475..79f007871 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/QrCodeScanView.kt @@ -1,12 +1,6 @@ package org.bitcoinppl.cove import android.Manifest -import android.content.Context -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager -import android.util.Base64 import android.util.Log import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -42,64 +36,17 @@ import com.google.accompanist.permissions.rememberPermissionState import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import org.bitcoinppl.cove_core.MultiQr +import org.bitcoinppl.cove_core.MultiFormat +import org.bitcoinppl.cove_core.QrScanner +import org.bitcoinppl.cove_core.ScanProgress +import org.bitcoinppl.cove_core.ScanResult import org.bitcoinppl.cove_core.StringOrData import java.util.concurrent.Executors -// haptic feedback helper -private fun triggerHapticFeedback(context: Context) { - try { - val vibrator = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager - vibratorManager?.defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - } - - vibrator?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - it.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - @Suppress("DEPRECATION") - it.vibrate(50) - } - } - } catch (e: Exception) { - Log.w("QrCodeScanView", "Failed to trigger haptic feedback", e) - } -} - -private sealed class QrCodeScannerState { - object Idle : QrCodeScannerState() - - data class Scanning( - val multiQr: MultiQr? = null, - val totalParts: UInt? = null, - val partsLeft: UInt? = null, - ) : QrCodeScannerState() { - val partsScanned: Int? - get() = - totalParts?.let { total -> - partsLeft?.let { left -> - (total - left).toInt() - } - } - - val isMultiPart: Boolean - get() = totalParts != null && partsLeft != null - } - - data class Complete( - val data: StringOrData, - ) : QrCodeScannerState() -} - @OptIn(ExperimentalPermissionsApi::class) @Composable fun QrCodeScanView( - onScanned: (StringOrData) -> Unit, + onScanned: (MultiFormat) -> Unit, onDismiss: () -> Unit, app: AppManager, modifier: Modifier = Modifier, @@ -198,7 +145,7 @@ private fun PermissionDeniedContent( @Composable @androidx.camera.core.ExperimentalGetImage private fun QrScannerContent( - onScanned: (StringOrData) -> Unit, + onScanned: (MultiFormat) -> Unit, onDismiss: () -> Unit, app: AppManager, modifier: Modifier = Modifier, @@ -206,16 +153,19 @@ private fun QrScannerContent( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - var scannerState by remember { mutableStateOf(QrCodeScannerState.Idle) } - val scannedCodes = remember { mutableSetOf() } + // Rust QR scanner state machine + val scanner = remember { QrScanner() } + var progress by remember { mutableStateOf(null) } + var scanComplete by remember { mutableStateOf(false) } + var scannedData by remember { mutableStateOf(null) } // handle scan completion - LaunchedEffect(scannerState) { - if (scannerState is QrCodeScannerState.Complete) { - val data = (scannerState as QrCodeScannerState.Complete).data + LaunchedEffect(scannedData) { + scannedData?.let { data -> onScanned(data) - // reset state to prevent re-trigger on recomposition - scannerState = QrCodeScannerState.Idle + scannedData = null + scanComplete = false + progress = null } } @@ -247,225 +197,236 @@ private fun QrScannerContent( } Box(modifier = modifier) { - when (val state = scannerState) { - is QrCodeScannerState.Complete -> { - // scanning complete, transitioning to onScanned callback - } - - else -> { - // camera preview for Idle and Scanning states - AndroidView( - factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - cameraProviderRef.value = cameraProvider - - val preview = - Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - previewRef.value = preview - - val imageAnalysis = - ImageAnalysis - .Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { analysis -> - analysis.setAnalyzer(executor) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = - InputImage.fromMediaImage( - mediaImage, - imageProxy.imageInfo.rotationDegrees, - ) - - val mainExecutor = ContextCompat.getMainExecutor(ctx) - barcodeScanner - .process(image) - .addOnSuccessListener(mainExecutor) { barcodes -> - for (barcode in barcodes) { - if (barcode.format == Barcode.FORMAT_QR_CODE) { - handleQrCode( - context = ctx, - currentState = scannerState, - barcode = barcode, - scannedCodes = scannedCodes, - onStateUpdate = { scannerState = it }, - onDismiss = onDismiss, - app = app, - ) - break - } + if (!scanComplete) { + // camera preview + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + cameraProviderRef.value = cameraProvider + + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + previewRef.value = preview + + val imageAnalysis = + ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(executor) { imageProxy -> + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = + InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees, + ) + + val mainExecutor = ContextCompat.getMainExecutor(ctx) + barcodeScanner + .process(image) + .addOnSuccessListener(mainExecutor) { barcodes -> + if (scanComplete) return@addOnSuccessListener + + for (barcode in barcodes) { + if (barcode.format == Barcode.FORMAT_QR_CODE) { + handleQrCode( + context = ctx, + barcode = barcode, + scanner = scanner, + onProgress = { progress = it }, + onComplete = { data -> + scanComplete = true + scannedData = data + scanner.reset() + }, + onError = { error -> + scanner.reset() + onDismiss() + app.alertState = + TaggedItem( + AppAlertState.General( + title = "QR Scan Error", + message = error, + ), + ) + }, + ) + break } - }.addOnFailureListener(mainExecutor) { e -> - Log.e("QrCodeScanView", "Barcode processing failed", e) - }.addOnCompleteListener { - imageProxy.close() } - } else { - imageProxy.close() - } + }.addOnFailureListener(mainExecutor) { e -> + Log.e("QrCodeScanView", "Barcode processing failed", e) + }.addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() } } - analysisRef.value = imageAnalysis - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - try { - cameraProvider.unbindAll() - val camera = - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageAnalysis, - ) - cameraRef.value = camera - camera.cameraControl.setZoomRatio(zoomLevel) - } catch (e: Exception) { - Log.e("QrCodeScanView", "Camera binding failed", e) - onDismiss() - app.alertState = - TaggedItem( - AppAlertState.General( - title = "QR Scan Error", - message = "Failed to initialize camera: ${e.message ?: "Unknown error"}", - ), - ) - } - }, ContextCompat.getMainExecutor(ctx)) + } + analysisRef.value = imageAnalysis + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + val camera = + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis, + ) + cameraRef.value = camera + camera.cameraControl.setZoomRatio(zoomLevel) + } catch (e: Exception) { + Log.e("QrCodeScanView", "Camera binding failed", e) + onDismiss() + app.alertState = + TaggedItem( + AppAlertState.General( + title = "QR Scan Error", + message = "Failed to initialize camera: ${e.message ?: "Unknown error"}", + ), + ) + } + }, ContextCompat.getMainExecutor(ctx)) - previewView - }, - modifier = Modifier.fillMaxSize(), - ) + previewView + }, + modifier = Modifier.fillMaxSize(), + ) - // flashlight and zoom controls - top of screen - Row( + // flashlight and zoom controls - top of screen + Row( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 60.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // flashlight toggle - top left + Box( modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 60.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .size(44.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + .clickable { toggleFlash() }, + contentAlignment = Alignment.Center, ) { - // flashlight toggle - top left - Box( - modifier = - Modifier - .size(44.dp) - .background(Color.Black.copy(alpha = 0.5f), CircleShape) - .clickable { toggleFlash() }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (isFlashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, - contentDescription = if (isFlashOn) "Turn off flashlight" else "Turn on flashlight", - tint = Color.White, - modifier = Modifier.size(24.dp), - ) - } - - // zoom toggle - top right - Box( - modifier = - Modifier - .size(44.dp) - .background(Color.Black.copy(alpha = 0.5f), CircleShape) - .clickable { toggleZoom() }, - contentAlignment = Alignment.Center, - ) { - Text( - text = if (zoomLevel == 1.2f) "1x" else "2x", - color = Color.White, - fontWeight = FontWeight.Medium, - ) - } + Icon( + imageVector = if (isFlashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, + contentDescription = if (isFlashOn) "Turn off flashlight" else "Turn on flashlight", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) } - // viewfinder overlay - centered - Canvas( + // zoom toggle - top right + Box( modifier = Modifier - .size(200.dp) - .align(Alignment.Center), + .size(44.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + .clickable { toggleZoom() }, + contentAlignment = Alignment.Center, ) { - val strokeWidth = 4.dp.toPx() - val cornerLength = 40.dp.toPx() - val color = Color.White.copy(alpha = 0.7f) + Text( + text = if (zoomLevel == 1.2f) "1x" else "2x", + color = Color.White, + fontWeight = FontWeight.Medium, + ) + } + } - // top-left corner - drawLine(color, Offset(0f, cornerLength), Offset(0f, 0f), strokeWidth) - drawLine(color, Offset(0f, 0f), Offset(cornerLength, 0f), strokeWidth) + // viewfinder overlay - centered + Canvas( + modifier = + Modifier + .size(200.dp) + .align(Alignment.Center), + ) { + val strokeWidth = 4.dp.toPx() + val cornerLength = 40.dp.toPx() + val color = Color.White.copy(alpha = 0.7f) - // top-right corner - drawLine(color, Offset(size.width - cornerLength, 0f), Offset(size.width, 0f), strokeWidth) - drawLine(color, Offset(size.width, 0f), Offset(size.width, cornerLength), strokeWidth) + // top-left corner + drawLine(color, Offset(0f, cornerLength), Offset(0f, 0f), strokeWidth) + drawLine(color, Offset(0f, 0f), Offset(cornerLength, 0f), strokeWidth) - // bottom-left corner - drawLine(color, Offset(0f, size.height - cornerLength), Offset(0f, size.height), strokeWidth) - drawLine(color, Offset(0f, size.height), Offset(cornerLength, size.height), strokeWidth) + // top-right corner + drawLine(color, Offset(size.width - cornerLength, 0f), Offset(size.width, 0f), strokeWidth) + drawLine(color, Offset(size.width, 0f), Offset(size.width, cornerLength), strokeWidth) - // bottom-right corner - drawLine(color, Offset(size.width - cornerLength, size.height), Offset(size.width, size.height), strokeWidth) - drawLine(color, Offset(size.width, size.height - cornerLength), Offset(size.width, size.height), strokeWidth) - } + // bottom-left corner + drawLine(color, Offset(0f, size.height - cornerLength), Offset(0f, size.height), strokeWidth) + drawLine(color, Offset(0f, size.height), Offset(cornerLength, size.height), strokeWidth) - // overlay content - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.weight(1f)) + // bottom-right corner + drawLine(color, Offset(size.width - cornerLength, size.height), Offset(size.width, size.height), strokeWidth) + drawLine(color, Offset(size.width, size.height - cornerLength), Offset(size.width, size.height), strokeWidth) + } - Text( - text = "Scan QR Code", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) + // overlay content + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.weight(5f)) - - // multi-part progress - if (state is QrCodeScannerState.Scanning && state.isMultiPart) { - Column( - modifier = - Modifier - .background(Color.Black.copy(alpha = 0.7f)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "Scanned ${state.partsScanned} of ${state.totalParts?.toInt()}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = Color.White, - ) + Text( + text = "Scan QR Code", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + + Spacer(modifier = Modifier.weight(5f)) + + // multi-part progress (uses displayText/detailText from Rust) + progress?.let { prog -> + Column( + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.7f)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = prog.displayText(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = Color.White, + ) + prog.detailText()?.let { detail -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${state.partsLeft?.toInt()} parts left", + text = detail, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.7f), ) } } - - Spacer(modifier = Modifier.weight(1f)) } + + Spacer(modifier = Modifier.weight(1f)) } } } @@ -484,27 +445,21 @@ private fun QrScannerContent( executor.shutdown() barcodeScanner.close() + scanner.close() } } } private fun handleQrCode( - context: Context, - currentState: QrCodeScannerState, + context: android.content.Context, barcode: Barcode, - scannedCodes: MutableSet, - onStateUpdate: (QrCodeScannerState) -> Unit, - onDismiss: () -> Unit, - app: AppManager, + scanner: QrScanner, + onProgress: (ScanProgress) -> Unit, + onComplete: (MultiFormat) -> Unit, + onError: (String) -> Unit, ) { - // guard against reprocessing after completion - if (currentState is QrCodeScannerState.Complete) { - return - } - try { - // check both rawBytes (binary) and rawValue (text) - // prioritize rawValue (text) if available, fall back to rawBytes (binary) + // convert barcode to StringOrData (prioritize text, fall back to binary) val qrData = when { barcode.rawValue != null -> StringOrData.String(barcode.rawValue!!) @@ -512,90 +467,18 @@ private fun handleQrCode( else -> return // no data available } - // deduplication: check if this code was already scanned - val qrDataString = - when (qrData) { - is StringOrData.String -> qrData.v1 - is StringOrData.Data -> Base64.encodeToString(qrData.v1, Base64.NO_WRAP) + // use Rust state machine to process the QR code + when (val result = scanner.scan(qrData)) { + is ScanResult.Complete -> { + result.haptic.trigger(context) + onComplete(result.data) } - - if (scannedCodes.contains(qrDataString)) { - return - } - scannedCodes.add(qrDataString) - - val scanningState = - when (currentState) { - is QrCodeScannerState.Scanning -> currentState - else -> QrCodeScannerState.Scanning() + is ScanResult.InProgress -> { + result.haptic.trigger(context) + onProgress(result.progress) } - - // try to create or use existing multi-qr - val multiQr = - scanningState.multiQr ?: try { - val newMultiQr = MultiQr.tryNew(qr = qrData) - val totalParts = newMultiQr.totalParts() - onStateUpdate( - QrCodeScannerState.Scanning( - multiQr = newMultiQr, - totalParts = totalParts, - ), - ) - newMultiQr - } catch (e: Exception) { - Log.d("QrCodeScanView", "Not a BBQr (single QR): ${e.message}") - // single QR code (not BBQr) - triggerHapticFeedback(context) - onStateUpdate(QrCodeScannerState.Complete(qrData)) - return - } - - // check if it's a BBQr - if (!multiQr.isBbqr()) { - triggerHapticFeedback(context) - onStateUpdate(QrCodeScannerState.Complete(qrData)) - return - } - - // for BBQr parts, we need to use the string representation - // extract the string from StringOrData for addPart - val qrString = - when (qrData) { - is StringOrData.String -> qrData.v1 - is StringOrData.Data -> { - // skip binary QR codes for multi-part BBQr, keep scanning - return - } - } - - // add part to BBQr - val result = multiQr.addPart(qr = qrString) - val partsLeft = result.partsLeft() - - // haptic feedback for each BBQr part scanned - triggerHapticFeedback(context) - - onStateUpdate( - scanningState.copy( - multiQr = multiQr, - partsLeft = partsLeft, - totalParts = scanningState.totalParts ?: multiQr.totalParts(), - ), - ) - - if (result.isComplete()) { - val finalData = result.finalResult() - // finalResult returns a string, so wrap it in StringOrData - onStateUpdate(QrCodeScannerState.Complete(StringOrData.String(finalData))) } } catch (e: Exception) { - onDismiss() - app.alertState = - TaggedItem( - AppAlertState.General( - title = "QR Scan Error", - message = "Unable to scan QR code, error: ${e.message ?: "Unknown scanning error"}", - ), - ) + onError("Unable to scan QR code: ${e.message ?: "Unknown scanning error"}") } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index ca3c14f7f..23a248e53 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -303,14 +303,22 @@ private fun SendFlowRouteToScreen( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { QrCodeScanView( - onScanned = { stringOrData -> + onScanned = { multiFormat -> presenter.sheetState = null - val scannedString = - when (stringOrData) { - is StringOrData.String -> stringOrData.v1 - is StringOrData.Data -> stringOrData.v1.toString(Charsets.UTF_8) + when (multiFormat) { + is MultiFormat.Address -> { + sendFlowManager.enteringAddress = multiFormat.v1.address().string() } - sendFlowManager.enteringAddress = scannedString + else -> { + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Invalid QR Code", + message = "Please scan a valid Bitcoin address QR code", + ), + ) + } + } }, onDismiss = { presenter.sheetState = null }, app = app, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt index 9b0221caa..308d6ce4f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/ColdWalletQrScanScreen.kt @@ -27,6 +27,7 @@ import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove_core.MultiFormat import org.bitcoinppl.cove_core.Wallet import org.bitcoinppl.cove_core.WalletException @@ -70,72 +71,70 @@ fun ColdWalletQrScanScreen(app: AppManager, modifier: Modifier = Modifier) { Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { QrCodeScanView( showTopBar = false, - onScanned = { stringOrData -> - try { - // try to parse as string first for xpub/descriptor - val xpub = - when (stringOrData) { - is org.bitcoinppl.cove_core.StringOrData.String -> stringOrData.v1 - is org.bitcoinppl.cove_core.StringOrData.Data -> - stringOrData.v1.toString(Charsets.UTF_8) - } - - val wallet = Wallet.newFromXpub(xpub = xpub) - val id = wallet.id() - wallet.close() - Log.d("ColdWalletQrScanScreen", "Imported Wallet: $id") + onScanned = { multiFormat -> + when (multiFormat) { + is MultiFormat.HardwareExport -> { + try { + val wallet = Wallet.newFromExport(export = multiFormat.v1) + val id = wallet.id() + wallet.close() + Log.d("ColdWalletQrScanScreen", "Imported Wallet: $id") - app.rust.selectWallet(id = id) - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.General( - title = "Success", - message = "Imported Wallet Successfully", - ), - ) - } catch (e: WalletException.MultiFormat) { - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = e.v1.toString(), - ), - ) - } catch (e: WalletException.WalletAlreadyExists) { - try { - app.rust.selectWallet(id = e.v1) + app.rust.selectWallet(id = id) + app.popRoute() + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Success", + message = "Imported Wallet Successfully", + ), + ) + } catch (e: WalletException.WalletAlreadyExists) { + try { + app.rust.selectWallet(id = e.v1) + app.popRoute() + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Success", + message = "Wallet already exists: ${e.v1}", + ), + ) + } catch (selectError: Exception) { + Log.w( + "ColdWalletQrScanScreen", + "Unable to select existing wallet", + selectError, + ) + app.popRoute() + app.alertState = + TaggedItem( + AppAlertState.ErrorImportingHardwareWallet( + message = selectError.message ?: "Unable to select wallet", + ), + ) + } + } catch (e: Exception) { + Log.w("ColdWalletQrScanScreen", "Error importing hardware wallet: $e") + app.popRoute() + app.alertState = + TaggedItem( + AppAlertState.ErrorImportingHardwareWallet( + message = e.message ?: "Unknown error", + ), + ) + } + } + else -> { app.popRoute() app.alertState = TaggedItem( AppAlertState.General( - title = "Success", - message = "Wallet already exists: ${e.v1}", - ), - ) - } catch (selectError: Exception) { - Log.w( - "ColdWalletQrScanScreen", - "Unable to select existing wallet", - selectError, - ) - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = selectError.message ?: "Unable to select wallet", + title = "Invalid QR Code", + message = "Please scan a valid hardware wallet export QR code", ), ) } - } catch (e: Exception) { - Log.w("ColdWalletQrScanScreen", "Error importing hardware wallet: $e") - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = e.message ?: "Unknown error", - ), - ) } }, onDismiss = { app.popRoute() }, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/QrCodeImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/QrCodeImportScreen.kt index 44e72d26d..783a3b7ce 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/QrCodeImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/cold_wallet/QrCodeImportScreen.kt @@ -1,13 +1,6 @@ package org.bitcoinppl.cove.flow.new_wallet.cold_wallet -import android.Manifest import android.util.Log -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,34 +8,21 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionState -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.TaggedItem -import org.bitcoinppl.cove_core.MultiQr +import org.bitcoinppl.cove_core.MultiFormat import org.bitcoinppl.cove_core.Wallet import org.bitcoinppl.cove_core.WalletException -import java.util.concurrent.Executors -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun QrCodeImportScreen(app: AppManager, modifier: Modifier = Modifier) { - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) var showHelp by remember { mutableStateOf(false) } Scaffold( @@ -78,380 +58,78 @@ fun QrCodeImportScreen(app: AppManager, modifier: Modifier = Modifier) { modifier = modifier.fillMaxSize(), ) { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - when { - cameraPermissionState.status.isGranted -> { - QrScannerContent( - app = app, - modifier = Modifier.fillMaxSize(), - ) - } - else -> { - PermissionDeniedContent( - app = app, - permissionState = cameraPermissionState, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } - - if (showHelp) { - HelpSheet(onDismiss = { showHelp = false }) - } -} - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -private fun PermissionDeniedContent( - app: AppManager, - permissionState: PermissionState, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = "Camera Access Required", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color.White, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Please allow camera access in Settings to use this feature.", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Button( - onClick = { - permissionState.launchPermissionRequest() - }, - ) { - Text("Grant Permission") - } - - Spacer(modifier = Modifier.height(12.dp)) - - TextButton( - onClick = { - app.popRoute() - app.alertState = TaggedItem(AppAlertState.NoCameraPermission) - }, - ) { - Text("Cancel", color = Color.White) - } - } -} - -@Composable -@androidx.camera.core.ExperimentalGetImage -private fun QrScannerContent( - app: AppManager, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - - var multiQr by remember { mutableStateOf(null) } - var scanComplete by remember { mutableStateOf(false) } - var totalParts by remember { mutableStateOf(null) } - var partsLeft by remember { mutableStateOf(null) } - var scannedCode by remember { mutableStateOf(null) } - - val partsScanned = - remember(totalParts, partsLeft) { - totalParts?.let { total -> - partsLeft?.let { left -> - (total - left).toInt() - } - } - } - - // handle wallet import when scannedCode changes - LaunchedEffect(scannedCode) { - scannedCode?.let { xpub -> - try { - val wallet = Wallet.newFromXpub(xpub = xpub) - val id = wallet.id() - wallet.close() - Log.d("QrCodeImportScreen", "Imported Wallet: $id") - - app.rust.selectWallet(id = id) - app.alertState = - TaggedItem( - AppAlertState.General( - title = "Success", - message = "Imported Wallet Successfully", - ), - ) - app.popRoute() - } catch (e: WalletException.MultiFormat) { - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = e.v1.toString(), - ), - ) - } catch (e: WalletException.WalletAlreadyExists) { - try { - app.rust.selectWallet(id = e.v1) - app.alertState = - TaggedItem( - AppAlertState.General( - title = "Success", - message = "Wallet already exists: ${e.v1}", - ), - ) - app.popRoute() - } catch (selectError: Exception) { - Log.e( - "QrCodeImportScreen", - "Unable to select existing wallet", - selectError, - ) - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = selectError.message ?: "Unable to select wallet", - ), - ) - } - } catch (e: Exception) { - Log.w("QrCodeImportScreen", "Error importing hardware wallet: $e") - app.popRoute() - app.alertState = - TaggedItem( - AppAlertState.ErrorImportingHardwareWallet( - message = e.message ?: "Unknown error", - ), - ) - } - } - } - - val barcodeScanner = remember { BarcodeScanning.getClient() } - val executor = remember { Executors.newSingleThreadExecutor() } - val cameraProviderRef = remember { mutableStateOf(null) } - val previewRef = remember { mutableStateOf(null) } - val analysisRef = remember { mutableStateOf(null) } - - Box(modifier = modifier) { - if (!scanComplete) { - // camera preview - AndroidView( - factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - cameraProviderRef.value = cameraProvider - - val preview = - Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - previewRef.value = preview - - val imageAnalysis = - ImageAnalysis - .Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { analysis -> - analysis.setAnalyzer(executor) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = - InputImage.fromMediaImage( - mediaImage, - imageProxy.imageInfo.rotationDegrees, - ) - - val mainExecutor = ContextCompat.getMainExecutor(ctx) - barcodeScanner - .process(image) - .addOnSuccessListener(mainExecutor) { barcodes -> - for (barcode in barcodes) { - if (barcode.format == Barcode.FORMAT_QR_CODE) { - handleQrCode( - barcode = barcode, - multiQr = multiQr, - onMultiQrUpdate = { multiQr = it }, - onTotalPartsUpdate = { totalParts = it }, - onPartsLeftUpdate = { partsLeft = it }, - onScanComplete = { - scanComplete = true - scannedCode = it - }, - onError = { error -> - Log.e("QrCodeImportScreen", "Error: $error") - app.alertState = - TaggedItem( - AppAlertState.General( - title = "QR Scan Error", - message = "Unable to scan QR code: $error", - ), - ) - }, - ) - break - } - } - }.addOnFailureListener(mainExecutor) { e -> - Log.e("QrCodeImportScreen", "Barcode processing failed", e) - }.addOnCompleteListener { - imageProxy.close() - } - } else { - imageProxy.close() - } - } + QrCodeScanView( + showTopBar = false, + onScanned = { multiFormat -> + when (multiFormat) { + is MultiFormat.HardwareExport -> { + try { + val wallet = Wallet.newFromExport(export = multiFormat.v1) + val id = wallet.id() + wallet.close() + Log.d("QrCodeImportScreen", "Imported Wallet: $id") + + app.rust.selectWallet(id = id) + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Success", + message = "Imported Wallet Successfully", + ), + ) + } catch (e: WalletException.WalletAlreadyExists) { + try { + app.rust.selectWallet(id = e.v1) + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Success", + message = "Wallet already exists: ${e.v1}", + ), + ) + } catch (selectError: Exception) { + Log.w( + "QrCodeImportScreen", + "Unable to select existing wallet", + selectError, + ) + app.alertState = + TaggedItem( + AppAlertState.ErrorImportingHardwareWallet( + message = selectError.message ?: "Unable to select wallet", + ), + ) } - analysisRef.value = imageAnalysis - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageAnalysis, - ) - } catch (e: Exception) { - Log.e("QrCodeImportScreen", "Camera binding failed", e) + } catch (e: Exception) { + Log.w("QrCodeImportScreen", "Error importing hardware wallet: $e") + app.alertState = + TaggedItem( + AppAlertState.ErrorImportingHardwareWallet( + message = e.message ?: "Unknown error", + ), + ) + } } - }, ContextCompat.getMainExecutor(ctx)) - - previewView + else -> { + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Invalid QR Code", + message = "Please scan a valid hardware wallet export QR code", + ), + ) + } + } }, + onDismiss = { app.popRoute() }, + app = app, modifier = Modifier.fillMaxSize(), ) - - // overlay content - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "Scan Wallet Export QR Code", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - - Spacer(modifier = Modifier.weight(5f)) - - // multi-part progress - if (totalParts != null && partsLeft != null) { - Column( - modifier = - Modifier - .background(Color.Black.copy(alpha = 0.7f)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "Scanned $partsScanned of ${totalParts?.toInt()}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = Color.White, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "${partsLeft?.toInt()} parts left", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = Color.White.copy(alpha = 0.7f), - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - } } } - DisposableEffect(Unit) { - onDispose { - analysisRef.value?.clearAnalyzer() - - cameraProviderRef.value?.let { cp -> - val p = previewRef.value - val a = analysisRef.value - if (p != null && a != null) { - cp.unbind(p, a) - } - } - - executor.shutdown() - barcodeScanner.close() - } - } -} - -private fun handleQrCode( - barcode: Barcode, - multiQr: MultiQr?, - onMultiQrUpdate: (MultiQr) -> Unit, - onTotalPartsUpdate: (UInt) -> Unit, - onPartsLeftUpdate: (UInt) -> Unit, - onScanComplete: (String) -> Unit, - onError: (String) -> Unit, -) { - try { - val qrString = barcode.rawValue ?: return - - // try to create or use existing multi-qr - val currentMultiQr = - multiQr ?: try { - val newMultiQr = MultiQr.newFromString(qr = qrString) - onMultiQrUpdate(newMultiQr) - onTotalPartsUpdate(newMultiQr.totalParts()) - newMultiQr - } catch (e: Exception) { - Log.d("QrCodeImportScreen", "Not a BBQr (single QR): ${e.message}") - // single QR code (not BBQr) - onScanComplete(qrString) - return - } - - // check if it's a BBQr - if (!currentMultiQr.isBbqr()) { - onScanComplete(qrString) - return - } - - // add part to BBQr - val result = currentMultiQr.addPart(qr = qrString) - onPartsLeftUpdate(result.partsLeft()) - - if (result.isComplete()) { - val finalData = result.finalResult() - onScanComplete(finalData) - } - } catch (e: Exception) { - onError(e.message ?: "Unknown error") + if (showHelp) { + HelpSheet(onDismiss = { showHelp = false }) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index 0174c4103..5c82b17e2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -1,12 +1,7 @@ package org.bitcoinppl.cove.flow.new_wallet.hot_wallet -import android.Manifest import android.app.Activity import android.util.Log -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -82,19 +77,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage import kotlinx.coroutines.delay +import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.ImportWalletManager +import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.nfc.NfcReadingState import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.title3 @@ -102,8 +91,6 @@ import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* -import java.util.concurrent.Executors -import androidx.camera.core.Preview as CameraPreview private const val GROUPS_OF = 12 @@ -129,7 +116,7 @@ private fun HotWalletImportScreenPreview() { ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HotWalletImportScreen( app: AppManager, @@ -157,7 +144,6 @@ fun HotWalletImportScreen( // QR and NFC state var showQrScanner by remember { mutableStateOf(false) } var showNfcScanner by remember { mutableStateOf(false) } - var multiQr by remember { mutableStateOf(null) } // auto-open scanner based on importType (matching iOS behavior) LaunchedEffect(importType) { @@ -188,7 +174,6 @@ fun HotWalletImportScreen( } // reset scanners - multiQr = null showQrScanner = false showNfcScanner = false @@ -432,10 +417,9 @@ fun HotWalletImportScreen( // QR Scanner Bottom Sheet if (showQrScanner) { QrScannerSheet( - numberOfWords = numberOfWords, + app = app, onDismiss = { showQrScanner = false - multiQr = null }, onWordsScanned = { words -> setWords(words) @@ -695,325 +679,42 @@ private fun WordInputField( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable -@androidx.camera.core.ExperimentalGetImage private fun QrScannerSheet( - numberOfWords: NumberOfBip39Words, + app: AppManager, onDismiss: () -> Unit, onWordsScanned: (List>) -> Unit, ) { - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - ModalBottomSheet( onDismissRequest = onDismiss, containerColor = Color.Black, ) { - if (cameraPermissionState.status.isGranted) { - QrScannerContent( - numberOfWords = numberOfWords, - onDismiss = onDismiss, - onWordsScanned = onWordsScanned, - modifier = Modifier.fillMaxSize(), - ) - } else { - // camera permission request - Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Camera Access Required", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color.White, - ) - - Text( - text = "Please allow camera access to scan QR codes", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - ) - - TextButton(onClick = { cameraPermissionState.launchPermissionRequest() }) { - Text("Grant Permission", color = Color.White) - } - - TextButton(onClick = onDismiss) { - Text("Cancel", color = Color.White.copy(alpha = 0.6f)) - } - } - } - } -} - -@Composable -@androidx.camera.core.ExperimentalGetImage -private fun QrScannerContent( - numberOfWords: NumberOfBip39Words, - onDismiss: () -> Unit, - onWordsScanned: (List>) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - - var multiQr by remember { mutableStateOf(null) } - var scanComplete by remember { mutableStateOf(false) } - var totalParts by remember { mutableStateOf(null) } - var partsLeft by remember { mutableStateOf(null) } - - val partsScanned = - remember(totalParts, partsLeft) { - totalParts?.let { total -> - partsLeft?.let { left -> - (total - left).toInt() - } - } - } - - val barcodeScanner = remember { BarcodeScanning.getClient() } - val executor = remember { Executors.newSingleThreadExecutor() } - val cameraProviderRef = remember { mutableStateOf(null) } - val previewRef = remember { mutableStateOf(null) } - val analysisRef = remember { mutableStateOf(null) } - - Box(modifier = modifier) { - if (!scanComplete) { - // camera preview - AndroidView( - factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - cameraProviderRef.value = cameraProvider - - val preview = - CameraPreview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - previewRef.value = preview - - val imageAnalysis = - ImageAnalysis - .Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { analysis -> - analysis.setAnalyzer(executor) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = - InputImage.fromMediaImage( - mediaImage, - imageProxy.imageInfo.rotationDegrees, - ) - - val mainExecutor = ContextCompat.getMainExecutor(ctx) - barcodeScanner - .process(image) - .addOnSuccessListener(mainExecutor) { barcodes -> - for (barcode in barcodes) { - if (barcode.format == Barcode.FORMAT_QR_CODE) { - handleQrCodeForSeed( - barcode = barcode, - numberOfWords = numberOfWords, - multiQr = multiQr, - onMultiQrUpdate = { multiQr = it }, - onTotalPartsUpdate = { totalParts = it }, - onPartsLeftUpdate = { partsLeft = it }, - onScanComplete = { words -> - scanComplete = true - onWordsScanned(words) - }, - onError = { error -> - Log.e("QrScannerSheet", "Error: $error") - onDismiss() - }, - ) - break - } - } - }.addOnFailureListener(mainExecutor) { e -> - Log.e("QrScannerSheet", "Barcode processing failed", e) - }.addOnCompleteListener { - imageProxy.close() - } - } else { - imageProxy.close() - } - } - } - analysisRef.value = imageAnalysis - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageAnalysis, + QrCodeScanView( + showTopBar = false, + onScanned = { multiFormat -> + when (multiFormat) { + is MultiFormat.Mnemonic -> { + val mnemonicString = multiFormat.v1.words().joinToString(" ") + val words = groupedPlainWordsOf(mnemonic = mnemonicString, groups = GROUPS_OF.toUByte()) + onWordsScanned(words) + } + else -> { + onDismiss() + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Invalid QR Code", + message = "Please scan a valid seed phrase QR code", + ), ) - } catch (e: Exception) { - Log.e("QrScannerSheet", "Camera binding failed", e) - } - }, ContextCompat.getMainExecutor(ctx)) - - previewView - }, - modifier = Modifier.fillMaxSize(), - ) - - // overlay content - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "Scan Seed Phrase QR Code", - style = MaterialTheme.typography.title3, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - - Spacer(modifier = Modifier.weight(5f)) - - // multi-part progress - if (totalParts != null && partsLeft != null) { - Column( - modifier = - Modifier - .background(Color.Black.copy(alpha = 0.7f)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "Scanned $partsScanned of ${totalParts?.toInt()}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = Color.White, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "${partsLeft?.toInt()} parts left", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = Color.White.copy(alpha = 0.7f), - ) } } - - Spacer(modifier = Modifier.weight(1f)) - } - } - } - - DisposableEffect(Unit) { - onDispose { - analysisRef.value?.clearAnalyzer() - - cameraProviderRef.value?.let { cp -> - val p = previewRef.value - val a = analysisRef.value - if (p != null && a != null) { - cp.unbind(p, a) - } - } - - executor.shutdown() - barcodeScanner.close() - } - } -} - -private fun handleQrCodeForSeed( - barcode: Barcode, - numberOfWords: NumberOfBip39Words, - multiQr: MultiQr?, - onMultiQrUpdate: (MultiQr) -> Unit, - onTotalPartsUpdate: (UInt) -> Unit, - onPartsLeftUpdate: (UInt) -> Unit, - onScanComplete: (List>) -> Unit, - onError: (String) -> Unit, -) { - try { - val qrString = barcode.rawValue ?: return - val qrBytes = barcode.rawBytes - - // try to parse as MultiQr first (for BBQr/SeedQR) - val currentMultiQr = - multiQr ?: try { - val newMultiQr = MultiQr.newFromString(qr = qrString) - onMultiQrUpdate(newMultiQr) - onTotalPartsUpdate(newMultiQr.totalParts()) - newMultiQr - } catch (e: Exception) { - Log.d("QrScannerSheet", "Not a BBQr, trying plain text: ${e.message}") - // try plain text mnemonic - tryParsePlainTextOrSeedQr(qrString, qrBytes, numberOfWords, onScanComplete, onError) - return - } - - // check if it's a BBQr - if (!currentMultiQr.isBbqr()) { - tryParsePlainTextOrSeedQr(qrString, qrBytes, numberOfWords, onScanComplete, onError) - return - } - - // add part to BBQr - val result = currentMultiQr.addPart(qr = qrString) - onPartsLeftUpdate(result.partsLeft()) - - if (result.isComplete()) { - val finalData = result.finalResult() - tryParsePlainTextOrSeedQr(finalData, null, numberOfWords, onScanComplete, onError) - } - } catch (e: Exception) { - onError(e.message ?: "Unknown error") - } -} - -private fun tryParsePlainTextOrSeedQr( - qrString: String, - qrBytes: ByteArray?, - numberOfWords: NumberOfBip39Words, - onScanComplete: (List>) -> Unit, - onError: (String) -> Unit, -) { - try { - // try parsing as plain text mnemonic first - val words = groupedPlainWordsOf(mnemonic = qrString, groups = GROUPS_OF.toUByte()) - onScanComplete(words) - } catch (e: Exception) { - Log.d("QrScannerSheet", "Not plain text, trying SeedQR: ${e.message}") - - // try parsing as SeedQR (binary format) - qrBytes?.let { bytes -> - try { - val seedQr = SeedQr.newFromData(data = bytes) - val words = seedQr.groupedPlainWords(groupsOf = GROUPS_OF.toUByte()) - onScanComplete(words) - return - } catch (e2: Exception) { - Log.d("QrScannerSheet", "Not SeedQR binary: ${e2.message}") - } - } - - onError("Unable to parse QR code as seed phrase") + }, + onDismiss = onDismiss, + app = app, + modifier = Modifier.fillMaxSize(), + ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt index 66bf5dccf..19e256c0d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/CoinControlSetAmountScreen.kt @@ -34,16 +34,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.sheets.FeeRateSelectorSheet import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton +import org.bitcoinppl.cove_core.MultiFormat import org.bitcoinppl.cove_core.SendFlowManagerAction -import org.bitcoinppl.cove_core.StringOrData @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -189,14 +191,22 @@ fun CoinControlSetAmountScreen( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { QrCodeScanView( - onScanned = { stringOrData -> + onScanned = { multiFormat -> presenter.sheetState = null - val scannedString = - when (stringOrData) { - is StringOrData.String -> stringOrData.v1 - is StringOrData.Data -> stringOrData.v1.toString(Charsets.UTF_8) + when (multiFormat) { + is MultiFormat.Address -> { + sendFlowManager.enteringAddress = multiFormat.v1.address().string() } - sendFlowManager.enteringAddress = scannedString + else -> { + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Invalid QR Code", + message = "Please scan a valid Bitcoin address QR code", + ), + ) + } + } }, onDismiss = { presenter.sheetState = null }, app = app, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt index 8b2be3783..329b63d40 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/WalletSettingsScreen.kt @@ -313,14 +313,15 @@ private fun WalletColorSelector( mutableStateOf(selectedWalletColor) } - val availableColors = remember { - try { - defaultWalletColors() - } catch (e: Throwable) { - Log.e("WalletSettingsScreen", "failed to load default wallet colors", e) - emptyList() + val availableColors = + remember { + try { + defaultWalletColors() + } catch (e: Throwable) { + Log.e("WalletSettingsScreen", "failed to load default wallet colors", e) + emptyList() + } } - } Column( Modifier diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index 8a70a5d27..c3cd3523b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -42,6 +42,7 @@ fun SidebarContainer( val context = LocalContext.current val density = LocalDensity.current val sidebarWidthPx = with(density) { SIDEBAR_WIDTH_DP.dp.toPx() } + val edgeThresholdPx = with(density) { EDGE_SWIPE_THRESHOLD_DP.dp.toPx() } var gestureOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 58bd6eeba..9b9509a95 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,9 +39,12 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.RouteFactory import org.bitcoinppl.cove_core.SettingsRoute diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index 5b7f0f25f..fb5072ec6 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -96,7 +96,6 @@ import org.bitcoinppl.cove_core.TransactionDetails import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletMetadata -import org.bitcoinppl.cove_core.types.BitcoinUnit import org.bitcoinppl.cove_core.types.FfiColorScheme import org.bitcoinppl.cove_core.types.TransactionDirection @@ -232,20 +231,21 @@ fun TransactionDetailsScreen( // 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 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( From 78a61feb1ddef00791aa325609a0329c6710930b Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 19:48:54 -0600 Subject: [PATCH 74/97] Update generated UniFFI bindings for QrScanner Add cove_ur bindings for UR format support and update cove_core bindings with QrScanner, ScanResult, ScanProgress, and HapticFeedback types. Exclude uniffi directory from ktlint. --- android/app/build.gradle.kts | 1 + .../java/org/bitcoinppl/cove_core/cove.kt | 4302 +++++++++-------- .../bitcoinppl/cove_core/types/cove_types.kt | 1110 ++++- .../src/main/java/uniffi/cove_ur/cove_ur.kt | 2546 ++++++++++ 4 files changed, 5565 insertions(+), 2394 deletions(-) create mode 100644 android/app/src/main/java/uniffi/cove_ur/cove_ur.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b288db153..af40e4d59 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -115,5 +115,6 @@ dependencies { ktlint { filter { exclude("**/cove_core/**") + exclude("**/uniffi/**") } } 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 7eabaa9fe..04f8e922b 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 @@ -106,6 +106,8 @@ import org.bitcoinppl.cove_core.types.Utxo import org.bitcoinppl.cove_core.types.UtxoList import org.bitcoinppl.cove_core.types.UtxoType import org.bitcoinppl.cove_core.types.WalletId +import uniffi.cove_ur.FfiConverterTypeUrError +import uniffi.cove_ur.UrException import org.bitcoinppl.cove_core.device.RustBuffer as RustBufferKeychainError import org.bitcoinppl.cove_core.nfc.RustBuffer as RustBufferNfcMessage import org.bitcoinppl.cove_core.tapcard.RustBuffer as RustBufferTapCardParseError @@ -140,6 +142,7 @@ import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxo import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxoList import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxoType import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferWalletId +import uniffi.cove_ur.RustBuffer as RustBufferUrError // This is a helper for safely working with byte buffers returned from the Rust code. // A rust-owned buffer is represented by its capacity, its current length, and a @@ -1487,28 +1490,6 @@ external fun uniffi_cove_checksum_method_mnemonic_all_words( ): Short external fun uniffi_cove_checksum_method_mnemonic_words( ): Short -external fun uniffi_cove_checksum_method_bbqrjoinresult_final_result( -): Short -external fun uniffi_cove_checksum_method_bbqrjoinresult_is_complete( -): Short -external fun uniffi_cove_checksum_method_bbqrjoinresult_parts_left( -): Short -external fun uniffi_cove_checksum_method_bbqrjoined_get_grouped_words( -): Short -external fun uniffi_cove_checksum_method_bbqrjoined_get_seed_words( -): Short -external fun uniffi_cove_checksum_method_multiqr_add_part( -): Short -external fun uniffi_cove_checksum_method_multiqr_get_grouped_words( -): Short -external fun uniffi_cove_checksum_method_multiqr_handle_scan_result( -): Short -external fun uniffi_cove_checksum_method_multiqr_is_bbqr( -): Short -external fun uniffi_cove_checksum_method_multiqr_is_seed_qr( -): Short -external fun uniffi_cove_checksum_method_multiqr_total_parts( -): Short external fun uniffi_cove_checksum_method_nodeselector_check_and_save_node( ): Short external fun uniffi_cove_checksum_method_nodeselector_check_selected_node( @@ -1521,6 +1502,10 @@ external fun uniffi_cove_checksum_method_nodeselector_select_preset_node( ): Short external fun uniffi_cove_checksum_method_nodeselector_selected_node( ): Short +external fun uniffi_cove_checksum_method_qrscanner_reset( +): Short +external fun uniffi_cove_checksum_method_qrscanner_scan( +): Short external fun uniffi_cove_checksum_method_boxedroute_route( ): Short external fun uniffi_cove_checksum_method_routefactory_coin_control_send( @@ -1667,6 +1652,16 @@ external fun uniffi_cove_checksum_method_unsignedtransaction_sending_amount( ): Short external fun uniffi_cove_checksum_method_unsignedtransaction_spending_amount( ): Short +external fun uniffi_cove_checksum_method_urresult_data( +): Short +external fun uniffi_cove_checksum_method_urresult_is_hdkey( +): Short +external fun uniffi_cove_checksum_method_urresult_is_psbt( +): Short +external fun uniffi_cove_checksum_method_urresult_is_seed( +): Short +external fun uniffi_cove_checksum_method_urresult_ur_type( +): Short external fun uniffi_cove_checksum_method_wallet_id( ): Short external fun uniffi_cove_checksum_method_balance_spendable( @@ -1743,14 +1738,10 @@ external fun uniffi_cove_checksum_constructor_mnemonic_new( ): Short external fun uniffi_cove_checksum_constructor_mnemonic_preview( ): Short -external fun uniffi_cove_checksum_constructor_multiqr_new_from_string( -): Short -external fun uniffi_cove_checksum_constructor_multiqr_try_new( -): Short -external fun uniffi_cove_checksum_constructor_multiqr_try_new_from_data( -): Short external fun uniffi_cove_checksum_constructor_nodeselector_new( ): Short +external fun uniffi_cove_checksum_constructor_qrscanner_new( +): Short external fun uniffi_cove_checksum_constructor_boxedroute_new( ): Short external fun uniffi_cove_checksum_constructor_routefactory_new( @@ -1787,6 +1778,8 @@ external fun uniffi_cove_checksum_constructor_transactiondetails_preview_pending ): Short external fun uniffi_cove_checksum_constructor_unsignedtransaction_preview_new( ): Short +external fun uniffi_cove_checksum_constructor_urresult_new( +): Short external fun uniffi_cove_checksum_constructor_wallet_new_from_export( ): Short external fun uniffi_cove_checksum_constructor_wallet_new_from_xpub( @@ -1856,6 +1849,7 @@ internal object UniffiLib { org.bitcoinppl.cove_core.nfc.uniffiEnsureInitialized() org.bitcoinppl.cove_core.tapcard.uniffiEnsureInitialized() org.bitcoinppl.cove_core.types.uniffiEnsureInitialized() + uniffi.cove_ur.uniffiEnsureInitialized() } external fun uniffi_cove_fn_clone_ffiapp(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2446,46 +2440,6 @@ external fun uniffi_cove_fn_clone_bip329labels(`handle`: Long,uniffi_out_err: Un ): Long external fun uniffi_cove_fn_free_bip329labels(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit -external fun uniffi_cove_fn_clone_bbqrjoinresult(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_free_bbqrjoinresult(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Unit -external fun uniffi_cove_fn_method_bbqrjoinresult_final_result(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -external fun uniffi_cove_fn_method_bbqrjoinresult_is_complete(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): Byte -external fun uniffi_cove_fn_method_bbqrjoinresult_parts_left(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): Int -external fun uniffi_cove_fn_clone_bbqrjoined(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_free_bbqrjoined(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Unit -external fun uniffi_cove_fn_method_bbqrjoined_get_grouped_words(`ptr`: Long,`chunks`: Byte,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -external fun uniffi_cove_fn_method_bbqrjoined_get_seed_words(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -external fun uniffi_cove_fn_clone_multiqr(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_free_multiqr(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Unit -external fun uniffi_cove_fn_constructor_multiqr_new_from_string(`qr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_constructor_multiqr_try_new(`qr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_constructor_multiqr_try_new_from_data(`data`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_method_multiqr_add_part(`ptr`: Long,`qr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Long -external fun uniffi_cove_fn_method_multiqr_get_grouped_words(`ptr`: Long,`qr`: RustBuffer.ByValue,`groupsOf`: Byte,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -external fun uniffi_cove_fn_method_multiqr_handle_scan_result(`ptr`: Long,`qr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -external fun uniffi_cove_fn_method_multiqr_is_bbqr(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): Byte -external fun uniffi_cove_fn_method_multiqr_is_seed_qr(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): Byte -external fun uniffi_cove_fn_method_multiqr_total_parts(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, -): Int external fun uniffi_cove_fn_clone_nodeselector(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_nodeselector(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2512,6 +2466,16 @@ external fun uniffi_cove_fn_clone_pushtx(`handle`: Long,uniffi_out_err: UniffiRu ): Long external fun uniffi_cove_fn_free_pushtx(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit +external fun uniffi_cove_fn_clone_qrscanner(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_free_qrscanner(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_fn_constructor_qrscanner_new(uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_method_qrscanner_reset(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_fn_method_qrscanner_scan(`ptr`: Long,`qr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_clone_boxedroute(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_boxedroute(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2738,6 +2702,22 @@ external fun uniffi_cove_fn_method_unsignedtransaction_sending_amount(`ptr`: Lon ): Long external fun uniffi_cove_fn_method_unsignedtransaction_spending_amount(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long +external fun uniffi_cove_fn_clone_urresult(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_free_urresult(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_fn_constructor_urresult_new(`data`: RustBuffer.ByValue,`urType`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_fn_method_urresult_data(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_urresult_is_hdkey(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_fn_method_urresult_is_psbt(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_fn_method_urresult_is_seed(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_fn_method_urresult_ur_type(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_clone_wallet(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_wallet(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2880,10 +2860,18 @@ external fun uniffi_cove_fn_method_mnemonicerror_uniffi_trait_display(`ptr`: Rus ): RustBuffer.ByValue external fun uniffi_cove_fn_method_mnemonicparseerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(`ptr`: RustBuffer.ByValue,`other`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_fn_method_multiformat_uniffi_trait_eq_ne(`ptr`: RustBuffer.ByValue,`other`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Byte external fun uniffi_cove_fn_method_multiformaterror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_multiqrerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_fn_method_scanprogress_detail_text(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_fn_method_scanprogress_display_text(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_method_bip39error_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_seedqrerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -3903,39 +3891,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_mnemonic_words() != 8009.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_bbqrjoinresult_final_result() != 4512.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_bbqrjoinresult_is_complete() != 57834.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_bbqrjoinresult_parts_left() != 18009.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_bbqrjoined_get_grouped_words() != 39118.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_bbqrjoined_get_seed_words() != 36742.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_add_part() != 4791.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_get_grouped_words() != 41176.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_handle_scan_result() != 10107.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_is_bbqr() != 45869.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_is_seed_qr() != 47501.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_multiqr_total_parts() != 39268.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_nodeselector_check_and_save_node() != 9328.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3954,6 +3909,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_nodeselector_selected_node() != 20791.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_qrscanner_reset() != 17017.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_qrscanner_scan() != 55003.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_boxedroute_route() != 6095.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4173,6 +4134,21 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_unsignedtransaction_spending_amount() != 60073.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_urresult_data() != 20992.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_urresult_is_hdkey() != 16134.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_urresult_is_psbt() != 29188.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_urresult_is_seed() != 5310.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_urresult_ur_type() != 33507.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_wallet_id() != 30585.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4287,16 +4263,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_constructor_mnemonic_preview() != 34768.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_constructor_multiqr_new_from_string() != 58512.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_constructor_multiqr_try_new() != 29401.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_constructor_multiqr_try_new_from_data() != 6690.toShort()) { + if (lib.uniffi_cove_checksum_constructor_nodeselector_new() != 62365.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_constructor_nodeselector_new() != 62365.toShort()) { + if (lib.uniffi_cove_checksum_constructor_qrscanner_new() != 57573.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_constructor_boxedroute_new() != 21632.toShort()) { @@ -4353,6 +4323,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_constructor_unsignedtransaction_preview_new() != 60973.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_constructor_urresult_new() != 34590.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_constructor_wallet_new_from_export() != 38500.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -6221,18 +6194,12 @@ public object FfiConverterTypeBalance: FfiConverter { // -public interface BbqrJoinResultInterface { - - fun `finalResult`(): kotlin.String - - fun `isComplete`(): kotlin.Boolean - - fun `partsLeft`(): kotlin.UInt +public interface Bip329LabelsInterface { companion object } -open class BbqrJoinResult: Disposable, AutoCloseable, BbqrJoinResultInterface +open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface { @Suppress("UNUSED_PARAMETER") @@ -6311,7 +6278,7 @@ open class BbqrJoinResult: Disposable, AutoCloseable, BbqrJoinResultInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bbqrjoinresult(handle, status) + UniffiLib.uniffi_cove_fn_free_bip329labels(handle, status) } } } @@ -6324,51 +6291,11 @@ open class BbqrJoinResult: Disposable, AutoCloseable, BbqrJoinResultInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bbqrjoinresult(handle, status) + UniffiLib.uniffi_cove_fn_clone_bip329labels(handle, status) } } - @Throws(MultiQrException::class)override fun `finalResult`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_bbqrjoinresult_final_result( - it, - _status) -} - } - ) - } - - - override fun `isComplete`(): kotlin.Boolean { - return FfiConverterBoolean.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bbqrjoinresult_is_complete( - it, - _status) -} - } - ) - } - - - override fun `partsLeft`(): kotlin.UInt { - return FfiConverterUInt.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bbqrjoinresult_parts_left( - it, - _status) -} - } - ) - } - - - @@ -6386,22 +6313,22 @@ open class BbqrJoinResult: Disposable, AutoCloseable, BbqrJoinResultInterface /** * @suppress */ -public object FfiConverterTypeBbqrJoinResult: FfiConverter { - override fun lower(value: BbqrJoinResult): Long { +public object FfiConverterTypeBip329Labels: FfiConverter { + override fun lower(value: Bip329Labels): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): BbqrJoinResult { - return BbqrJoinResult(UniffiWithHandle, value) + override fun lift(value: Long): Bip329Labels { + return Bip329Labels(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): BbqrJoinResult { + override fun read(buf: ByteBuffer): Bip329Labels { return lift(buf.getLong()) } - override fun allocationSize(value: BbqrJoinResult) = 8UL + override fun allocationSize(value: Bip329Labels) = 8UL - override fun write(value: BbqrJoinResult, buf: ByteBuffer) { + override fun write(value: Bip329Labels, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -6502,16 +6429,23 @@ public object FfiConverterTypeBbqrJoinResult: FfiConverter // -public interface BbqrJoinedInterface { +public interface Bip39AutoCompleteInterface { - fun `getGroupedWords`(`chunks`: kotlin.UByte): List> + fun `autocomplete`(`word`: kotlin.String): List - fun `getSeedWords`(): List + fun `isValidWord`(`word`: kotlin.String): kotlin.Boolean + + /** + * Find the next invalid or empty field number + */ + fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte companion object } -open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface +open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterface +, AutoComplete + { @Suppress("UNUSED_PARAMETER") @@ -6535,6 +6469,14 @@ open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface this.handle = 0 this.cleanable = null } + constructor() : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_bip39autocomplete_new( + + _status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -6590,7 +6532,7 @@ open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bbqrjoined(handle, status) + UniffiLib.uniffi_cove_fn_free_bip39autocomplete(handle, status) } } } @@ -6603,18 +6545,30 @@ open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bbqrjoined(handle, status) + UniffiLib.uniffi_cove_fn_clone_bip39autocomplete(handle, status) } } + override fun `autocomplete`(`word`: kotlin.String): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39autocomplete_autocomplete( + it, + FfiConverterString.lower(`word`),_status) +} + } + ) + } - @Throws(MultiQrException::class)override fun `getGroupedWords`(`chunks`: kotlin.UByte): List> { - return FfiConverterSequenceSequenceString.lift( + + override fun `isValidWord`(`word`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_bbqrjoined_get_grouped_words( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39autocomplete_is_valid_word( it, - FfiConverterUByte.lower(`chunks`),_status) + FfiConverterString.lower(`word`),_status) } } ) @@ -6622,13 +6576,15 @@ open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface - @Throws(MultiQrException::class)override fun `getSeedWords`(): List { - return FfiConverterSequenceString.lift( + /** + * Find the next invalid or empty field number + */override fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte { + return FfiConverterUByte.lift( callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_bbqrjoined_get_seed_words( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39autocomplete_next_field_number( it, - _status) + FfiConverterUByte.lower(`currentFieldNumber`),FfiConverterSequenceString.lower(`enteredWords`),_status) } } ) @@ -6653,22 +6609,22 @@ open class BbqrJoined: Disposable, AutoCloseable, BbqrJoinedInterface /** * @suppress */ -public object FfiConverterTypeBbqrJoined: FfiConverter { - override fun lower(value: BbqrJoined): Long { +public object FfiConverterTypeBip39AutoComplete: FfiConverter { + override fun lower(value: Bip39AutoComplete): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): BbqrJoined { - return BbqrJoined(UniffiWithHandle, value) + override fun lift(value: Long): Bip39AutoComplete { + return Bip39AutoComplete(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): BbqrJoined { + override fun read(buf: ByteBuffer): Bip39AutoComplete { return lift(buf.getLong()) } - override fun allocationSize(value: BbqrJoined) = 8UL + override fun allocationSize(value: Bip39AutoComplete) = 8UL - override fun write(value: BbqrJoined, buf: ByteBuffer) { + override fun write(value: Bip39AutoComplete, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -6769,12 +6725,20 @@ public object FfiConverterTypeBbqrJoined: FfiConverter { // -public interface Bip329LabelsInterface { +public interface Bip39WordSpecificAutocompleteInterface { + + fun `autocomplete`(`word`: kotlin.String, `allWords`: List>): List + + fun `isBip39Word`(`word`: kotlin.String): kotlin.Boolean + + fun `isValidWord`(`word`: kotlin.String, `allWords`: List>): kotlin.Boolean + + fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte companion object } -open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface +open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSpecificAutocompleteInterface { @Suppress("UNUSED_PARAMETER") @@ -6798,6 +6762,14 @@ open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface this.handle = 0 this.cleanable = null } + constructor(`wordNumber`: kotlin.UShort, `numberOfWords`: NumberOfBip39Words) : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_bip39wordspecificautocomplete_new( + + FfiConverterUShort.lower(`wordNumber`),FfiConverterTypeNumberOfBip39Words.lower(`numberOfWords`),_status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -6853,7 +6825,7 @@ open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bip329labels(handle, status) + UniffiLib.uniffi_cove_fn_free_bip39wordspecificautocomplete(handle, status) } } } @@ -6866,10 +6838,62 @@ open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bip329labels(handle, status) + UniffiLib.uniffi_cove_fn_clone_bip39wordspecificautocomplete(handle, status) } } + override fun `autocomplete`(`word`: kotlin.String, `allWords`: List>): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_autocomplete( + it, + FfiConverterString.lower(`word`),FfiConverterSequenceSequenceString.lower(`allWords`),_status) +} + } + ) + } + + + override fun `isBip39Word`(`word`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_is_bip39_word( + it, + FfiConverterString.lower(`word`),_status) +} + } + ) + } + + + override fun `isValidWord`(`word`: kotlin.String, `allWords`: List>): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_is_valid_word( + it, + FfiConverterString.lower(`word`),FfiConverterSequenceSequenceString.lower(`allWords`),_status) +} + } + ) + } + + + override fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte { + return FfiConverterUByte.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_next_field_number( + it, + FfiConverterUByte.lower(`currentFieldNumber`),FfiConverterSequenceString.lower(`enteredWords`),_status) +} + } + ) + } + + @@ -6888,22 +6912,22 @@ open class Bip329Labels: Disposable, AutoCloseable, Bip329LabelsInterface /** * @suppress */ -public object FfiConverterTypeBip329Labels: FfiConverter { - override fun lower(value: Bip329Labels): Long { +public object FfiConverterTypeBip39WordSpecificAutocomplete: FfiConverter { + override fun lower(value: Bip39WordSpecificAutocomplete): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Bip329Labels { - return Bip329Labels(UniffiWithHandle, value) + override fun lift(value: Long): Bip39WordSpecificAutocomplete { + return Bip39WordSpecificAutocomplete(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Bip329Labels { + override fun read(buf: ByteBuffer): Bip39WordSpecificAutocomplete { return lift(buf.getLong()) } - override fun allocationSize(value: Bip329Labels) = 8UL + override fun allocationSize(value: Bip39WordSpecificAutocomplete) = 8UL - override fun write(value: Bip329Labels, buf: ByteBuffer) { + override fun write(value: Bip39WordSpecificAutocomplete, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -7004,23 +7028,18 @@ public object FfiConverterTypeBip329Labels: FfiConverter { // -public interface Bip39AutoCompleteInterface { +public interface BitcoinTransactionInterface { - fun `autocomplete`(`word`: kotlin.String): List + fun `normalizeTxId`(): kotlin.String - fun `isValidWord`(`word`: kotlin.String): kotlin.Boolean + fun `txId`(): TxId - /** - * Find the next invalid or empty field number - */ - fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte + fun `txIdHash`(): kotlin.String companion object } -open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterface -, AutoComplete - +open class BitcoinTransaction: Disposable, AutoCloseable, BitcoinTransactionInterface { @Suppress("UNUSED_PARAMETER") @@ -7044,12 +7063,12 @@ open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterf this.handle = 0 this.cleanable = null } - constructor() : + constructor(`txHex`: kotlin.String) : this(UniffiWithHandle, - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_bip39autocomplete_new( + uniffiRustCallWithError(BitcoinTransactionException) { _status -> + UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_new( - _status) + FfiConverterString.lower(`txHex`),_status) } ) @@ -7107,7 +7126,7 @@ open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterf return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bip39autocomplete(handle, status) + UniffiLib.uniffi_cove_fn_free_bitcointransaction(handle, status) } } } @@ -7120,46 +7139,43 @@ open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterf throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bip39autocomplete(handle, status) + UniffiLib.uniffi_cove_fn_clone_bitcointransaction(handle, status) } } - override fun `autocomplete`(`word`: kotlin.String): List { - return FfiConverterSequenceString.lift( + override fun `normalizeTxId`(): kotlin.String { + return FfiConverterString.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39autocomplete_autocomplete( + UniffiLib.uniffi_cove_fn_method_bitcointransaction_normalize_tx_id( it, - FfiConverterString.lower(`word`),_status) + _status) } } ) } - override fun `isValidWord`(`word`: kotlin.String): kotlin.Boolean { - return FfiConverterBoolean.lift( + override fun `txId`(): TxId { + return FfiConverterTypeTxId.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39autocomplete_is_valid_word( + UniffiLib.uniffi_cove_fn_method_bitcointransaction_tx_id( it, - FfiConverterString.lower(`word`),_status) + _status) } } ) } - - /** - * Find the next invalid or empty field number - */override fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte { - return FfiConverterUByte.lift( + override fun `txIdHash`(): kotlin.String { + return FfiConverterString.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39autocomplete_next_field_number( + UniffiLib.uniffi_cove_fn_method_bitcointransaction_tx_id_hash( it, - FfiConverterUByte.lower(`currentFieldNumber`),FfiConverterSequenceString.lower(`enteredWords`),_status) + _status) } } ) @@ -7172,11 +7188,45 @@ open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterf + companion object { + + @Throws(BitcoinTransactionException::class) fun `tryFromData`(`data`: kotlin.ByteArray): BitcoinTransaction { + return FfiConverterTypeBitcoinTransaction.lift( + uniffiRustCallWithError(BitcoinTransactionException) { _status -> + UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromdata( + + FfiConverterByteArray.lower(`data`),_status) +} + ) + } - /** - * @suppress - */ - companion object + + + @Throws(BitcoinTransactionException::class) fun `tryFromNfcMessage`(`nfcMessage`: NfcMessage): BitcoinTransaction { + return FfiConverterTypeBitcoinTransaction.lift( + uniffiRustCallWithError(BitcoinTransactionException) { _status -> + UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromnfcmessage( + + FfiConverterTypeNfcMessage.lower(`nfcMessage`),_status) +} + ) + } + + + + @Throws(BitcoinTransactionException::class) fun `tryFromStringOrData`(`stringOrData`: StringOrData): BitcoinTransaction { + return FfiConverterTypeBitcoinTransaction.lift( + uniffiRustCallWithError(BitcoinTransactionException) { _status -> + UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromstringordata( + + FfiConverterTypeStringOrData.lower(`stringOrData`),_status) +} + ) + } + + + + } } @@ -7184,22 +7234,22 @@ open class Bip39AutoComplete: Disposable, AutoCloseable, Bip39AutoCompleteInterf /** * @suppress */ -public object FfiConverterTypeBip39AutoComplete: FfiConverter { - override fun lower(value: Bip39AutoComplete): Long { +public object FfiConverterTypeBitcoinTransaction: FfiConverter { + override fun lower(value: BitcoinTransaction): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Bip39AutoComplete { - return Bip39AutoComplete(UniffiWithHandle, value) + override fun lift(value: Long): BitcoinTransaction { + return BitcoinTransaction(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Bip39AutoComplete { + override fun read(buf: ByteBuffer): BitcoinTransaction { return lift(buf.getLong()) } - override fun allocationSize(value: Bip39AutoComplete) = 8UL + override fun allocationSize(value: BitcoinTransaction) = 8UL - override fun write(value: Bip39AutoComplete, buf: ByteBuffer) { + override fun write(value: BitcoinTransaction, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -7300,20 +7350,14 @@ public object FfiConverterTypeBip39AutoComplete: FfiConverter>): List - - fun `isBip39Word`(`word`: kotlin.String): kotlin.Boolean - - fun `isValidWord`(`word`: kotlin.String, `allWords`: List>): kotlin.Boolean +public interface BoxedRouteInterface { - fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte + fun `route`(): Route companion object } -open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSpecificAutocompleteInterface +open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface { @Suppress("UNUSED_PARAMETER") @@ -7337,12 +7381,12 @@ open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSp this.handle = 0 this.cleanable = null } - constructor(`wordNumber`: kotlin.UShort, `numberOfWords`: NumberOfBip39Words) : + constructor(`route`: Route) : this(UniffiWithHandle, uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_bip39wordspecificautocomplete_new( + UniffiLib.uniffi_cove_fn_constructor_boxedroute_new( - FfiConverterUShort.lower(`wordNumber`),FfiConverterTypeNumberOfBip39Words.lower(`numberOfWords`),_status) + FfiConverterTypeRoute.lower(`route`),_status) } ) @@ -7400,7 +7444,7 @@ open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSp return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bip39wordspecificautocomplete(handle, status) + UniffiLib.uniffi_cove_fn_free_boxedroute(handle, status) } } } @@ -7413,56 +7457,17 @@ open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSp throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bip39wordspecificautocomplete(handle, status) + UniffiLib.uniffi_cove_fn_clone_boxedroute(handle, status) } } - override fun `autocomplete`(`word`: kotlin.String, `allWords`: List>): List { - return FfiConverterSequenceString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_autocomplete( - it, - FfiConverterString.lower(`word`),FfiConverterSequenceSequenceString.lower(`allWords`),_status) -} - } - ) - } - - - override fun `isBip39Word`(`word`: kotlin.String): kotlin.Boolean { - return FfiConverterBoolean.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_is_bip39_word( - it, - FfiConverterString.lower(`word`),_status) -} - } - ) - } - - - override fun `isValidWord`(`word`: kotlin.String, `allWords`: List>): kotlin.Boolean { - return FfiConverterBoolean.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_is_valid_word( - it, - FfiConverterString.lower(`word`),FfiConverterSequenceSequenceString.lower(`allWords`),_status) -} - } - ) - } - - - override fun `nextFieldNumber`(`currentFieldNumber`: kotlin.UByte, `enteredWords`: List): kotlin.UByte { - return FfiConverterUByte.lift( + override fun `route`(): Route { + return FfiConverterTypeRoute.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bip39wordspecificautocomplete_next_field_number( + UniffiLib.uniffi_cove_fn_method_boxedroute_route( it, - FfiConverterUByte.lower(`currentFieldNumber`),FfiConverterSequenceString.lower(`enteredWords`),_status) + _status) } } ) @@ -7487,22 +7492,22 @@ open class Bip39WordSpecificAutocomplete: Disposable, AutoCloseable, Bip39WordSp /** * @suppress */ -public object FfiConverterTypeBip39WordSpecificAutocomplete: FfiConverter { - override fun lower(value: Bip39WordSpecificAutocomplete): Long { +public object FfiConverterTypeBoxedRoute: FfiConverter { + override fun lower(value: BoxedRoute): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Bip39WordSpecificAutocomplete { - return Bip39WordSpecificAutocomplete(UniffiWithHandle, value) + override fun lift(value: Long): BoxedRoute { + return BoxedRoute(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Bip39WordSpecificAutocomplete { + override fun read(buf: ByteBuffer): BoxedRoute { return lift(buf.getLong()) } - override fun allocationSize(value: Bip39WordSpecificAutocomplete) = 8UL + override fun allocationSize(value: BoxedRoute) = 8UL - override fun write(value: Bip39WordSpecificAutocomplete, buf: ByteBuffer) { + override fun write(value: BoxedRoute, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -7603,18 +7608,12 @@ public object FfiConverterTypeBip39WordSpecificAutocomplete: FfiConverter - UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_new( - - FfiConverterString.lower(`txHex`),_status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -7701,7 +7692,7 @@ open class BitcoinTransaction: Disposable, AutoCloseable, BitcoinTransactionInte return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_bitcointransaction(handle, status) + UniffiLib.uniffi_cove_fn_free_coincontrolmanagerstate(handle, status) } } } @@ -7714,49 +7705,10 @@ open class BitcoinTransaction: Disposable, AutoCloseable, BitcoinTransactionInte throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_bitcointransaction(handle, status) + UniffiLib.uniffi_cove_fn_clone_coincontrolmanagerstate(handle, status) } } - override fun `normalizeTxId`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bitcointransaction_normalize_tx_id( - it, - _status) -} - } - ) - } - - - override fun `txId`(): TxId { - return FfiConverterTypeTxId.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bitcointransaction_tx_id( - it, - _status) -} - } - ) - } - - - override fun `txIdHash`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_bitcointransaction_tx_id_hash( - it, - _status) -} - } - ) - } - - @@ -7764,37 +7716,12 @@ open class BitcoinTransaction: Disposable, AutoCloseable, BitcoinTransactionInte companion object { - - @Throws(BitcoinTransactionException::class) fun `tryFromData`(`data`: kotlin.ByteArray): BitcoinTransaction { - return FfiConverterTypeBitcoinTransaction.lift( - uniffiRustCallWithError(BitcoinTransactionException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromdata( - - FfiConverterByteArray.lower(`data`),_status) -} - ) - } - - - - @Throws(BitcoinTransactionException::class) fun `tryFromNfcMessage`(`nfcMessage`: NfcMessage): BitcoinTransaction { - return FfiConverterTypeBitcoinTransaction.lift( - uniffiRustCallWithError(BitcoinTransactionException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromnfcmessage( - - FfiConverterTypeNfcMessage.lower(`nfcMessage`),_status) -} - ) - } - - - - @Throws(BitcoinTransactionException::class) fun `tryFromStringOrData`(`stringOrData`: StringOrData): BitcoinTransaction { - return FfiConverterTypeBitcoinTransaction.lift( - uniffiRustCallWithError(BitcoinTransactionException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_bitcointransaction_tryfromstringordata( + fun `previewNew`(`outputCount`: kotlin.UByte = 20u, `changeCount`: kotlin.UByte = 4u): CoinControlManagerState { + return FfiConverterTypeCoinControlManagerState.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_coincontrolmanagerstate_preview_new( - FfiConverterTypeStringOrData.lower(`stringOrData`),_status) + FfiConverterUByte.lower(`outputCount`),FfiConverterUByte.lower(`changeCount`),_status) } ) } @@ -7809,22 +7736,22 @@ open class BitcoinTransaction: Disposable, AutoCloseable, BitcoinTransactionInte /** * @suppress */ -public object FfiConverterTypeBitcoinTransaction: FfiConverter { - override fun lower(value: BitcoinTransaction): Long { +public object FfiConverterTypeCoinControlManagerState: FfiConverter { + override fun lower(value: CoinControlManagerState): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): BitcoinTransaction { - return BitcoinTransaction(UniffiWithHandle, value) + override fun lift(value: Long): CoinControlManagerState { + return CoinControlManagerState(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): BitcoinTransaction { + override fun read(buf: ByteBuffer): CoinControlManagerState { return lift(buf.getLong()) } - override fun allocationSize(value: BitcoinTransaction) = 8UL + override fun allocationSize(value: CoinControlManagerState) = 8UL - override fun write(value: BitcoinTransaction, buf: ByteBuffer) { + override fun write(value: CoinControlManagerState, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -7925,14 +7852,32 @@ public object FfiConverterTypeBitcoinTransaction: FfiConverter - UniffiLib.uniffi_cove_fn_constructor_boxedroute_new( - - FfiConverterTypeRoute.lower(`route`),_status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -8019,7 +7956,7 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_boxedroute(handle, status) + UniffiLib.uniffi_cove_fn_free_confirmedtransaction(handle, status) } } } @@ -8032,15 +7969,132 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_boxedroute(handle, status) + UniffiLib.uniffi_cove_fn_clone_confirmedtransaction(handle, status) } } - override fun `route`(): Route { - return FfiConverterTypeRoute.lift( + override fun `blockHeight`(): kotlin.UInt { + return FfiConverterUInt.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_boxedroute_route( + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height( + it, + _status) +} + } + ) + } + + + override fun `blockHeightFmt`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height_fmt( + it, + _status) +} + } + ) + } + + + override fun `confirmedAt`(): kotlin.ULong { + return FfiConverterULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at( + it, + _status) +} + } + ) + } + + + override fun `confirmedAtFmt`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt( + it, + _status) +} + } + ) + } + + + override fun `confirmedAtFmtWithTime`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt_with_time( + it, + _status) +} + } + ) + } + + + override fun `fiatAmount`(): FiatAmount? { + return FfiConverterOptionalTypeFiatAmount.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_fiat_amount( + it, + _status) +} + } + ) + } + + + override fun `id`(): TxId { + return FfiConverterTypeTxId.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_id( + it, + _status) +} + } + ) + } + + + override fun `label`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label( + it, + _status) +} + } + ) + } + + + override fun `labelOpt`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label_opt( + it, + _status) +} + } + ) + } + + + override fun `sentAndReceived`(): SentAndReceived { + return FfiConverterTypeSentAndReceived.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_sent_and_received( it, _status) } @@ -8067,22 +8121,22 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface /** * @suppress */ -public object FfiConverterTypeBoxedRoute: FfiConverter { - override fun lower(value: BoxedRoute): Long { +public object FfiConverterTypeConfirmedTransaction: FfiConverter { + override fun lower(value: ConfirmedTransaction): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): BoxedRoute { - return BoxedRoute(UniffiWithHandle, value) + override fun lift(value: Long): ConfirmedTransaction { + return ConfirmedTransaction(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): BoxedRoute { + override fun read(buf: ByteBuffer): ConfirmedTransaction { return lift(buf.getLong()) } - override fun allocationSize(value: BoxedRoute) = 8UL + override fun allocationSize(value: ConfirmedTransaction) = 8UL - override fun write(value: BoxedRoute, buf: ByteBuffer) { + override fun write(value: ConfirmedTransaction, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -8183,12 +8237,16 @@ public object FfiConverterTypeBoxedRoute: FfiConverter { // -public interface CoinControlManagerStateInterface { +public interface ConverterInterface { + + fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double + + fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String companion object } -open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManagerStateInterface +open class Converter: Disposable, AutoCloseable, ConverterInterface { @Suppress("UNUSED_PARAMETER") @@ -8212,6 +8270,14 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage this.handle = 0 this.cleanable = null } + constructor() : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_converter_new( + + _status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -8267,7 +8333,7 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_coincontrolmanagerstate(handle, status) + UniffiLib.uniffi_cove_fn_free_converter(handle, status) } } } @@ -8280,30 +8346,48 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_coincontrolmanagerstate(handle, status) + UniffiLib.uniffi_cove_fn_clone_converter(handle, status) } } - + @Throws(ConverterException::class)override fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double { + return FfiConverterDouble.lift( + callWithHandle { + uniffiRustCallWithError(ConverterException) { _status -> + UniffiLib.uniffi_cove_fn_method_converter_parse_fiat_str( + it, + FfiConverterString.lower(`fiatAmount`),_status) +} + } + ) + } - - - companion object { - fun `previewNew`(`outputCount`: kotlin.UByte = 20u, `changeCount`: kotlin.UByte = 4u): CoinControlManagerState { - return FfiConverterTypeCoinControlManagerState.lift( + override fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_coincontrolmanagerstate_preview_new( - - FfiConverterUByte.lower(`outputCount`),FfiConverterUByte.lower(`changeCount`),_status) + UniffiLib.uniffi_cove_fn_method_converter_remove_fiat_suffix( + it, + FfiConverterString.lower(`fiatAmount`),_status) } + } ) } - - } + + + + + + + + /** + * @suppress + */ + companion object } @@ -8311,22 +8395,22 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage /** * @suppress */ -public object FfiConverterTypeCoinControlManagerState: FfiConverter { - override fun lower(value: CoinControlManagerState): Long { +public object FfiConverterTypeConverter: FfiConverter { + override fun lower(value: Converter): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): CoinControlManagerState { - return CoinControlManagerState(UniffiWithHandle, value) + override fun lift(value: Long): Converter { + return Converter(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): CoinControlManagerState { + override fun read(buf: ByteBuffer): Converter { return lift(buf.getLong()) } - override fun allocationSize(value: CoinControlManagerState) = 8UL + override fun allocationSize(value: Converter) = 8UL - override fun write(value: CoinControlManagerState, buf: ByteBuffer) { + override fun write(value: Converter, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -8427,683 +8511,24 @@ public object FfiConverterTypeCoinControlManagerState: FfiConverter callWithHandle(block: (handle: Long) -> R): R { - // Check and increment the call counter, to keep the object alive. - // This needs a compare-and-set retry loop in case of concurrent updates. - do { - val c = this.callCounter.get() - if (c == 0L) { - throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") - } - if (c == Long.MAX_VALUE) { - throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") - } - } while (! this.callCounter.compareAndSet(c, c + 1L)) - // Now we can safely do the method call without the handle being freed concurrently. - try { - return block(this.uniffiCloneHandle()) - } finally { - // This decrement always matches the increment we performed above. - if (this.callCounter.decrementAndGet() == 0L) { - cleanable?.clean() - } - } - } - - // Use a static inner class instead of a closure so as not to accidentally - // capture `this` as part of the cleanable's action. - private class UniffiCleanAction(private val handle: Long) : Runnable { - override fun run() { - if (handle == 0.toLong()) { - // Fake object created with `NoHandle`, don't try to free. - return; - } - uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_confirmedtransaction(handle, status) - } - } - } - - /** - * @suppress - */ - fun uniffiCloneHandle(): Long { - if (handle == 0.toLong()) { - throw InternalException("uniffiCloneHandle() called on NoHandle object"); - } - return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_confirmedtransaction(handle, status) - } - } - - override fun `blockHeight`(): kotlin.UInt { - return FfiConverterUInt.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height( - it, - _status) -} - } - ) - } - - - override fun `blockHeightFmt`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height_fmt( - it, - _status) -} - } - ) - } - - - override fun `confirmedAt`(): kotlin.ULong { - return FfiConverterULong.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at( - it, - _status) -} - } - ) - } - - - override fun `confirmedAtFmt`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt( - it, - _status) -} - } - ) - } - - - override fun `confirmedAtFmtWithTime`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt_with_time( - it, - _status) -} - } - ) - } - - - override fun `fiatAmount`(): FiatAmount? { - return FfiConverterOptionalTypeFiatAmount.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_fiat_amount( - it, - _status) -} - } - ) - } - - - override fun `id`(): TxId { - return FfiConverterTypeTxId.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_id( - it, - _status) -} - } - ) - } - - - override fun `label`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label( - it, - _status) -} - } - ) - } - - - override fun `labelOpt`(): kotlin.String? { - return FfiConverterOptionalString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label_opt( - it, - _status) -} - } - ) - } - - - override fun `sentAndReceived`(): SentAndReceived { - return FfiConverterTypeSentAndReceived.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_sent_and_received( - it, - _status) -} - } - ) - } - - - - - - - - - - /** - * @suppress - */ - companion object - -} - - -/** - * @suppress - */ -public object FfiConverterTypeConfirmedTransaction: FfiConverter { - override fun lower(value: ConfirmedTransaction): Long { - return value.uniffiCloneHandle() - } - - override fun lift(value: Long): ConfirmedTransaction { - return ConfirmedTransaction(UniffiWithHandle, value) - } - - override fun read(buf: ByteBuffer): ConfirmedTransaction { - return lift(buf.getLong()) - } - - override fun allocationSize(value: ConfirmedTransaction) = 8UL - - override fun write(value: ConfirmedTransaction, buf: ByteBuffer) { - buf.putLong(lower(value)) - } -} - - -// This template implements a class for working with a Rust struct via a handle -// to the live Rust struct on the other side of the FFI. -// -// There's some subtlety here, because we have to be careful not to operate on a Rust -// struct after it has been dropped, and because we must expose a public API for freeing -// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: -// -// * Each instance holds an opaque handle to the underlying Rust struct. -// Method calls need to read this handle from the object's state and pass it in to -// the Rust FFI. -// -// * When an instance is no longer needed, its handle should be passed to a -// special destructor function provided by the Rust FFI, which will drop the -// underlying Rust struct. -// -// * Given an instance, calling code is expected to call the special -// `destroy` method in order to free it after use, either by calling it explicitly -// or by using a higher-level helper like the `use` method. Failing to do so risks -// leaking the underlying Rust struct. -// -// * We can't assume that calling code will do the right thing, and must be prepared -// to handle Kotlin method calls executing concurrently with or even after a call to -// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. -// -// * We must never allow Rust code to operate on the underlying Rust struct after -// the destructor has been called, and must never call the destructor more than once. -// Doing so may trigger memory unsafety. -// -// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` -// is implemented to call the destructor when the Kotlin object becomes unreachable. -// This is done in a background thread. This is not a panacea, and client code should be aware that -// 1. the thread may starve if some there are objects that have poorly performing -// `drop` methods or do significant work in their `drop` methods. -// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, -// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). -// -// If we try to implement this with mutual exclusion on access to the handle, there is the -// possibility of a race between a method call and a concurrent call to `destroy`: -// -// * Thread A starts a method call, reads the value of the handle, but is interrupted -// before it can pass the handle over the FFI to Rust. -// * Thread B calls `destroy` and frees the underlying Rust struct. -// * Thread A resumes, passing the already-read handle value to Rust and triggering -// a use-after-free. -// -// One possible solution would be to use a `ReadWriteLock`, with each method call taking -// a read lock (and thus allowed to run concurrently) and the special `destroy` method -// taking a write lock (and thus blocking on live method calls). However, we aim not to -// generate methods with any hidden blocking semantics, and a `destroy` method that might -// block if called incorrectly seems to meet that bar. -// -// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track -// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` -// has been called. These are updated according to the following rules: -// -// * The initial value of the counter is 1, indicating a live object with no in-flight calls. -// The initial value for the flag is false. -// -// * At the start of each method call, we atomically check the counter. -// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. -// If it is nonzero them we atomically increment it by 1 and proceed with the method call. -// -// * At the end of each method call, we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// * When `destroy` is called, we atomically flip the flag from false to true. -// If the flag was already true we silently fail. -// Otherwise we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, -// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. -// -// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been -// called *and* all in-flight method calls have completed, avoiding violating any of the expectations -// of the underlying Rust code. -// -// This makes a cleaner a better alternative to _not_ calling `destroy()` as -// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` -// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner -// thread may be starved, and the app will leak memory. -// -// In this case, `destroy`ing manually may be a better solution. -// -// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects -// with Rust peers are reclaimed: -// -// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: -// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: -// 3. The memory is reclaimed when the process terminates. -// -// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 -// - - -public interface ConverterInterface { - - fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double - - fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String - - companion object -} - -open class Converter: Disposable, AutoCloseable, ConverterInterface -{ - - @Suppress("UNUSED_PARAMETER") - /** - * @suppress - */ - constructor(withHandle: UniffiWithHandle, handle: Long) { - this.handle = handle - this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) - } - - /** - * @suppress - * - * This constructor can be used to instantiate a fake object. Only used for tests. Any - * attempt to actually use an object constructed this way will fail as there is no - * connected Rust object. - */ - @Suppress("UNUSED_PARAMETER") - constructor(noHandle: NoHandle) { - this.handle = 0 - this.cleanable = null - } - constructor() : - this(UniffiWithHandle, - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_converter_new( - - _status) -} - ) - - protected val handle: Long - protected val cleanable: UniffiCleaner.Cleanable? - - private val wasDestroyed = AtomicBoolean(false) - private val callCounter = AtomicLong(1) - - override fun destroy() { - // Only allow a single call to this method. - // TODO: maybe we should log a warning if called more than once? - if (this.wasDestroyed.compareAndSet(false, true)) { - // This decrement always matches the initial count of 1 given at creation time. - if (this.callCounter.decrementAndGet() == 0L) { - cleanable?.clean() - } - } - } - - @Synchronized - override fun close() { - this.destroy() - } - - internal inline fun callWithHandle(block: (handle: Long) -> R): R { - // Check and increment the call counter, to keep the object alive. - // This needs a compare-and-set retry loop in case of concurrent updates. - do { - val c = this.callCounter.get() - if (c == 0L) { - throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") - } - if (c == Long.MAX_VALUE) { - throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") - } - } while (! this.callCounter.compareAndSet(c, c + 1L)) - // Now we can safely do the method call without the handle being freed concurrently. - try { - return block(this.uniffiCloneHandle()) - } finally { - // This decrement always matches the increment we performed above. - if (this.callCounter.decrementAndGet() == 0L) { - cleanable?.clean() - } - } - } - - // Use a static inner class instead of a closure so as not to accidentally - // capture `this` as part of the cleanable's action. - private class UniffiCleanAction(private val handle: Long) : Runnable { - override fun run() { - if (handle == 0.toLong()) { - // Fake object created with `NoHandle`, don't try to free. - return; - } - uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_converter(handle, status) - } - } - } - - /** - * @suppress - */ - fun uniffiCloneHandle(): Long { - if (handle == 0.toLong()) { - throw InternalException("uniffiCloneHandle() called on NoHandle object"); - } - return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_converter(handle, status) - } - } - - - @Throws(ConverterException::class)override fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double { - return FfiConverterDouble.lift( - callWithHandle { - uniffiRustCallWithError(ConverterException) { _status -> - UniffiLib.uniffi_cove_fn_method_converter_parse_fiat_str( - it, - FfiConverterString.lower(`fiatAmount`),_status) -} - } - ) - } - - - override fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_converter_remove_fiat_suffix( - it, - FfiConverterString.lower(`fiatAmount`),_status) -} - } - ) - } - - - - - - - - - - /** - * @suppress - */ - companion object - -} - - -/** - * @suppress - */ -public object FfiConverterTypeConverter: FfiConverter { - override fun lower(value: Converter): Long { - return value.uniffiCloneHandle() - } - - override fun lift(value: Long): Converter { - return Converter(UniffiWithHandle, value) - } - - override fun read(buf: ByteBuffer): Converter { - return lift(buf.getLong()) - } - - override fun allocationSize(value: Converter) = 8UL - - override fun write(value: Converter, buf: ByteBuffer) { - buf.putLong(lower(value)) - } -} - - -// This template implements a class for working with a Rust struct via a handle -// to the live Rust struct on the other side of the FFI. -// -// There's some subtlety here, because we have to be careful not to operate on a Rust -// struct after it has been dropped, and because we must expose a public API for freeing -// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: -// -// * Each instance holds an opaque handle to the underlying Rust struct. -// Method calls need to read this handle from the object's state and pass it in to -// the Rust FFI. -// -// * When an instance is no longer needed, its handle should be passed to a -// special destructor function provided by the Rust FFI, which will drop the -// underlying Rust struct. -// -// * Given an instance, calling code is expected to call the special -// `destroy` method in order to free it after use, either by calling it explicitly -// or by using a higher-level helper like the `use` method. Failing to do so risks -// leaking the underlying Rust struct. -// -// * We can't assume that calling code will do the right thing, and must be prepared -// to handle Kotlin method calls executing concurrently with or even after a call to -// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. -// -// * We must never allow Rust code to operate on the underlying Rust struct after -// the destructor has been called, and must never call the destructor more than once. -// Doing so may trigger memory unsafety. -// -// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` -// is implemented to call the destructor when the Kotlin object becomes unreachable. -// This is done in a background thread. This is not a panacea, and client code should be aware that -// 1. the thread may starve if some there are objects that have poorly performing -// `drop` methods or do significant work in their `drop` methods. -// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, -// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). -// -// If we try to implement this with mutual exclusion on access to the handle, there is the -// possibility of a race between a method call and a concurrent call to `destroy`: -// -// * Thread A starts a method call, reads the value of the handle, but is interrupted -// before it can pass the handle over the FFI to Rust. -// * Thread B calls `destroy` and frees the underlying Rust struct. -// * Thread A resumes, passing the already-read handle value to Rust and triggering -// a use-after-free. -// -// One possible solution would be to use a `ReadWriteLock`, with each method call taking -// a read lock (and thus allowed to run concurrently) and the special `destroy` method -// taking a write lock (and thus blocking on live method calls). However, we aim not to -// generate methods with any hidden blocking semantics, and a `destroy` method that might -// block if called incorrectly seems to meet that bar. -// -// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track -// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` -// has been called. These are updated according to the following rules: -// -// * The initial value of the counter is 1, indicating a live object with no in-flight calls. -// The initial value for the flag is false. -// -// * At the start of each method call, we atomically check the counter. -// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. -// If it is nonzero them we atomically increment it by 1 and proceed with the method call. -// -// * At the end of each method call, we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// * When `destroy` is called, we atomically flip the flag from false to true. -// If the flag was already true we silently fail. -// Otherwise we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, -// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. -// -// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been -// called *and* all in-flight method calls have completed, avoiding violating any of the expectations -// of the underlying Rust code. -// -// This makes a cleaner a better alternative to _not_ calling `destroy()` as -// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` -// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner -// thread may be starved, and the app will leak memory. -// -// In this case, `destroy`ing manually may be a better solution. -// -// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects -// with Rust peers are reclaimed: -// -// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: -// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: -// 3. The memory is reclaimed when the process terminates. -// -// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 -// - - -public interface DatabaseInterface { - - fun `dangerousResetAllData`() - - fun `globalConfig`(): GlobalConfigTable - - fun `globalFlag`(): GlobalFlagTable - - fun `historicalPrices`(): HistoricalPriceTable - - fun `unsignedTransactions`(): UnsignedTransactionsTable - - fun `wallets`(): WalletsTable - - companion object -} - -open class Database: Disposable, AutoCloseable, DatabaseInterface +open class Database: Disposable, AutoCloseable, DatabaseInterface { @Suppress("UNUSED_PARAMETER") @@ -12665,281 +12090,7 @@ open class HeaderIconPresenter: Disposable, AutoCloseable, HeaderIconPresenterIn return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_headericonpresenter(handle, status) - } - } - } - - /** - * @suppress - */ - fun uniffiCloneHandle(): Long { - if (handle == 0.toLong()) { - throw InternalException("uniffiCloneHandle() called on NoHandle object"); - } - return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_headericonpresenter(handle, status) - } - } - - override fun `backgroundColor`(`state`: TransactionState, `direction`: TransactionDirection, `colorScheme`: FfiColorScheme, `confirmationCount`: kotlin.Long): FfiColor { - return FfiConverterTypeFfiColor.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_headericonpresenter_background_color( - it, - FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterLong.lower(`confirmationCount`),_status) -} - } - ) - } - - - override fun `iconColor`(`state`: TransactionState, `direction`: TransactionDirection, `colorScheme`: FfiColorScheme, `confirmationCount`: kotlin.Long): FfiColor { - return FfiConverterTypeFfiColor.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_headericonpresenter_icon_color( - it, - FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterLong.lower(`confirmationCount`),_status) -} - } - ) - } - - - override fun `ringColor`(`state`: TransactionState, `colorScheme`: FfiColorScheme, `direction`: TransactionDirection, `confirmations`: kotlin.Long, `ringNumber`: kotlin.Long): FfiColor { - return FfiConverterTypeFfiColor.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_headericonpresenter_ring_color( - it, - FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterLong.lower(`confirmations`),FfiConverterLong.lower(`ringNumber`),_status) -} - } - ) - } - - - - - - - - - - /** - * @suppress - */ - companion object - -} - - -/** - * @suppress - */ -public object FfiConverterTypeHeaderIconPresenter: FfiConverter { - override fun lower(value: HeaderIconPresenter): Long { - return value.uniffiCloneHandle() - } - - override fun lift(value: Long): HeaderIconPresenter { - return HeaderIconPresenter(UniffiWithHandle, value) - } - - override fun read(buf: ByteBuffer): HeaderIconPresenter { - return lift(buf.getLong()) - } - - override fun allocationSize(value: HeaderIconPresenter) = 8UL - - override fun write(value: HeaderIconPresenter, buf: ByteBuffer) { - buf.putLong(lower(value)) - } -} - - -// This template implements a class for working with a Rust struct via a handle -// to the live Rust struct on the other side of the FFI. -// -// There's some subtlety here, because we have to be careful not to operate on a Rust -// struct after it has been dropped, and because we must expose a public API for freeing -// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: -// -// * Each instance holds an opaque handle to the underlying Rust struct. -// Method calls need to read this handle from the object's state and pass it in to -// the Rust FFI. -// -// * When an instance is no longer needed, its handle should be passed to a -// special destructor function provided by the Rust FFI, which will drop the -// underlying Rust struct. -// -// * Given an instance, calling code is expected to call the special -// `destroy` method in order to free it after use, either by calling it explicitly -// or by using a higher-level helper like the `use` method. Failing to do so risks -// leaking the underlying Rust struct. -// -// * We can't assume that calling code will do the right thing, and must be prepared -// to handle Kotlin method calls executing concurrently with or even after a call to -// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. -// -// * We must never allow Rust code to operate on the underlying Rust struct after -// the destructor has been called, and must never call the destructor more than once. -// Doing so may trigger memory unsafety. -// -// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` -// is implemented to call the destructor when the Kotlin object becomes unreachable. -// This is done in a background thread. This is not a panacea, and client code should be aware that -// 1. the thread may starve if some there are objects that have poorly performing -// `drop` methods or do significant work in their `drop` methods. -// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, -// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). -// -// If we try to implement this with mutual exclusion on access to the handle, there is the -// possibility of a race between a method call and a concurrent call to `destroy`: -// -// * Thread A starts a method call, reads the value of the handle, but is interrupted -// before it can pass the handle over the FFI to Rust. -// * Thread B calls `destroy` and frees the underlying Rust struct. -// * Thread A resumes, passing the already-read handle value to Rust and triggering -// a use-after-free. -// -// One possible solution would be to use a `ReadWriteLock`, with each method call taking -// a read lock (and thus allowed to run concurrently) and the special `destroy` method -// taking a write lock (and thus blocking on live method calls). However, we aim not to -// generate methods with any hidden blocking semantics, and a `destroy` method that might -// block if called incorrectly seems to meet that bar. -// -// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track -// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` -// has been called. These are updated according to the following rules: -// -// * The initial value of the counter is 1, indicating a live object with no in-flight calls. -// The initial value for the flag is false. -// -// * At the start of each method call, we atomically check the counter. -// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. -// If it is nonzero them we atomically increment it by 1 and proceed with the method call. -// -// * At the end of each method call, we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// * When `destroy` is called, we atomically flip the flag from false to true. -// If the flag was already true we silently fail. -// Otherwise we atomically decrement and check the counter. -// If it has reached zero then we destroy the underlying Rust struct. -// -// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, -// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. -// -// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been -// called *and* all in-flight method calls have completed, avoiding violating any of the expectations -// of the underlying Rust code. -// -// This makes a cleaner a better alternative to _not_ calling `destroy()` as -// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` -// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner -// thread may be starved, and the app will leak memory. -// -// In this case, `destroy`ing manually may be a better solution. -// -// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects -// with Rust peers are reclaimed: -// -// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: -// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: -// 3. The memory is reclaimed when the process terminates. -// -// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 -// - - -public interface HistoricalPriceTableInterface { - - companion object -} - -open class HistoricalPriceTable: Disposable, AutoCloseable, HistoricalPriceTableInterface -{ - - @Suppress("UNUSED_PARAMETER") - /** - * @suppress - */ - constructor(withHandle: UniffiWithHandle, handle: Long) { - this.handle = handle - this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) - } - - /** - * @suppress - * - * This constructor can be used to instantiate a fake object. Only used for tests. Any - * attempt to actually use an object constructed this way will fail as there is no - * connected Rust object. - */ - @Suppress("UNUSED_PARAMETER") - constructor(noHandle: NoHandle) { - this.handle = 0 - this.cleanable = null - } - - protected val handle: Long - protected val cleanable: UniffiCleaner.Cleanable? - - private val wasDestroyed = AtomicBoolean(false) - private val callCounter = AtomicLong(1) - - override fun destroy() { - // Only allow a single call to this method. - // TODO: maybe we should log a warning if called more than once? - if (this.wasDestroyed.compareAndSet(false, true)) { - // This decrement always matches the initial count of 1 given at creation time. - if (this.callCounter.decrementAndGet() == 0L) { - cleanable?.clean() - } - } - } - - @Synchronized - override fun close() { - this.destroy() - } - - internal inline fun callWithHandle(block: (handle: Long) -> R): R { - // Check and increment the call counter, to keep the object alive. - // This needs a compare-and-set retry loop in case of concurrent updates. - do { - val c = this.callCounter.get() - if (c == 0L) { - throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") - } - if (c == Long.MAX_VALUE) { - throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") - } - } while (! this.callCounter.compareAndSet(c, c + 1L)) - // Now we can safely do the method call without the handle being freed concurrently. - try { - return block(this.uniffiCloneHandle()) - } finally { - // This decrement always matches the increment we performed above. - if (this.callCounter.decrementAndGet() == 0L) { - cleanable?.clean() - } - } - } - - // Use a static inner class instead of a closure so as not to accidentally - // capture `this` as part of the cleanable's action. - private class UniffiCleanAction(private val handle: Long) : Runnable { - override fun run() { - if (handle == 0.toLong()) { - // Fake object created with `NoHandle`, don't try to free. - return; - } - uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_historicalpricetable(handle, status) + UniffiLib.uniffi_cove_fn_free_headericonpresenter(handle, status) } } } @@ -12952,10 +12103,49 @@ open class HistoricalPriceTable: Disposable, AutoCloseable, HistoricalPriceTable throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_historicalpricetable(handle, status) + UniffiLib.uniffi_cove_fn_clone_headericonpresenter(handle, status) } } + override fun `backgroundColor`(`state`: TransactionState, `direction`: TransactionDirection, `colorScheme`: FfiColorScheme, `confirmationCount`: kotlin.Long): FfiColor { + return FfiConverterTypeFfiColor.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_headericonpresenter_background_color( + it, + FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterLong.lower(`confirmationCount`),_status) +} + } + ) + } + + + override fun `iconColor`(`state`: TransactionState, `direction`: TransactionDirection, `colorScheme`: FfiColorScheme, `confirmationCount`: kotlin.Long): FfiColor { + return FfiConverterTypeFfiColor.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_headericonpresenter_icon_color( + it, + FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterLong.lower(`confirmationCount`),_status) +} + } + ) + } + + + override fun `ringColor`(`state`: TransactionState, `colorScheme`: FfiColorScheme, `direction`: TransactionDirection, `confirmations`: kotlin.Long, `ringNumber`: kotlin.Long): FfiColor { + return FfiConverterTypeFfiColor.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_headericonpresenter_ring_color( + it, + FfiConverterTypeTransactionState.lower(`state`),FfiConverterTypeFfiColorScheme.lower(`colorScheme`),FfiConverterTypeTransactionDirection.lower(`direction`),FfiConverterLong.lower(`confirmations`),FfiConverterLong.lower(`ringNumber`),_status) +} + } + ) + } + + @@ -12974,22 +12164,22 @@ open class HistoricalPriceTable: Disposable, AutoCloseable, HistoricalPriceTable /** * @suppress */ -public object FfiConverterTypeHistoricalPriceTable: FfiConverter { - override fun lower(value: HistoricalPriceTable): Long { +public object FfiConverterTypeHeaderIconPresenter: FfiConverter { + override fun lower(value: HeaderIconPresenter): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): HistoricalPriceTable { - return HistoricalPriceTable(UniffiWithHandle, value) + override fun lift(value: Long): HeaderIconPresenter { + return HeaderIconPresenter(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): HistoricalPriceTable { + override fun read(buf: ByteBuffer): HeaderIconPresenter { return lift(buf.getLong()) } - override fun allocationSize(value: HistoricalPriceTable) = 8UL + override fun allocationSize(value: HeaderIconPresenter) = 8UL - override fun write(value: HistoricalPriceTable, buf: ByteBuffer) { + override fun write(value: HeaderIconPresenter, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -13090,12 +12280,12 @@ public object FfiConverterTypeHistoricalPriceTable: FfiConverter - UniffiLib.uniffi_cove_fn_free_historicalpricesresponse(handle, status) + UniffiLib.uniffi_cove_fn_free_historicalpricetable(handle, status) } } } @@ -13187,7 +12377,7 @@ open class HistoricalPricesResponse: Disposable, AutoCloseable, HistoricalPrices throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_historicalpricesresponse(handle, status) + UniffiLib.uniffi_cove_fn_clone_historicalpricetable(handle, status) } } @@ -13209,22 +12399,22 @@ open class HistoricalPricesResponse: Disposable, AutoCloseable, HistoricalPrices /** * @suppress */ -public object FfiConverterTypeHistoricalPricesResponse: FfiConverter { - override fun lower(value: HistoricalPricesResponse): Long { +public object FfiConverterTypeHistoricalPriceTable: FfiConverter { + override fun lower(value: HistoricalPriceTable): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): HistoricalPricesResponse { - return HistoricalPricesResponse(UniffiWithHandle, value) + override fun lift(value: Long): HistoricalPriceTable { + return HistoricalPriceTable(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): HistoricalPricesResponse { + override fun read(buf: ByteBuffer): HistoricalPriceTable { return lift(buf.getLong()) } - override fun allocationSize(value: HistoricalPricesResponse) = 8UL + override fun allocationSize(value: HistoricalPriceTable) = 8UL - override fun write(value: HistoricalPricesResponse, buf: ByteBuffer) { + override fun write(value: HistoricalPriceTable, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -13325,28 +12515,12 @@ public object FfiConverterTypeHistoricalPricesResponse: FfiConverter - UniffiLib.uniffi_cove_fn_constructor_labelmanager_new( - - FfiConverterTypeWalletId.lower(`id`),_status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -13433,7 +12599,7 @@ open class LabelManager: Disposable, AutoCloseable, LabelManagerInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_labelmanager(handle, status) + UniffiLib.uniffi_cove_fn_free_historicalpricesresponse(handle, status) } } } @@ -13446,116 +12612,11 @@ open class LabelManager: Disposable, AutoCloseable, LabelManagerInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_labelmanager(handle, status) + UniffiLib.uniffi_cove_fn_clone_historicalpricesresponse(handle, status) } } - @Throws(LabelManagerException::class)override fun `deleteLabelsForTxn`(`txId`: TxId) - = - callWithHandle { - uniffiRustCallWithError(LabelManagerException) { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_delete_labels_for_txn( - it, - FfiConverterTypeTxId.lower(`txId`),_status) -} - } - - - - - @Throws(LabelManagerException::class)override fun `export`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCallWithError(LabelManagerException) { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_export( - it, - _status) -} - } - ) - } - - - override fun `exportDefaultFileName`(`name`: kotlin.String): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_export_default_file_name( - it, - FfiConverterString.lower(`name`),_status) -} - } - ) - } - - - override fun `hasLabels`(): kotlin.Boolean { - return FfiConverterBoolean.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_has_labels( - it, - _status) -} - } - ) - } - - - - @Throws(LabelManagerException::class)override fun `import`(`jsonl`: kotlin.String) - = - callWithHandle { - uniffiRustCallWithError(LabelManagerException) { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_import( - it, - FfiConverterString.lower(`jsonl`),_status) -} - } - - - - - @Throws(LabelManagerException::class)override fun `importLabels`(`labels`: Bip329Labels) - = - callWithHandle { - uniffiRustCallWithError(LabelManagerException) { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_importlabels( - it, - FfiConverterTypeBip329Labels.lower(`labels`),_status) -} - } - - - - - @Throws(LabelManagerException::class)override fun `insertOrUpdateLabelsForTxn`(`details`: TransactionDetails, `label`: kotlin.String, `origin`: kotlin.String?) - = - callWithHandle { - uniffiRustCallWithError(LabelManagerException) { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_insert_or_update_labels_for_txn( - it, - FfiConverterTypeTransactionDetails.lower(`details`),FfiConverterString.lower(`label`),FfiConverterOptionalString.lower(`origin`),_status) -} - } - - - - override fun `transactionLabel`(`txId`: TxId): kotlin.String? { - return FfiConverterOptionalString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_labelmanager_transaction_label( - it, - FfiConverterTypeTxId.lower(`txId`),_status) -} - } - ) - } - - - @@ -13573,22 +12634,22 @@ open class LabelManager: Disposable, AutoCloseable, LabelManagerInterface /** * @suppress */ -public object FfiConverterTypeLabelManager: FfiConverter { - override fun lower(value: LabelManager): Long { +public object FfiConverterTypeHistoricalPricesResponse: FfiConverter { + override fun lower(value: HistoricalPricesResponse): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): LabelManager { - return LabelManager(UniffiWithHandle, value) + override fun lift(value: Long): HistoricalPricesResponse { + return HistoricalPricesResponse(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): LabelManager { + override fun read(buf: ByteBuffer): HistoricalPricesResponse { return lift(buf.getLong()) } - override fun allocationSize(value: LabelManager) = 8UL + override fun allocationSize(value: HistoricalPricesResponse) = 8UL - override fun write(value: LabelManager, buf: ByteBuffer) { + override fun write(value: HistoricalPricesResponse, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -13689,12 +12750,28 @@ public object FfiConverterTypeLabelManager: FfiConverter { // -public interface LabelsTableInterface { +public interface LabelManagerInterface { + + fun `deleteLabelsForTxn`(`txId`: TxId) + + fun `export`(): kotlin.String + + fun `exportDefaultFileName`(`name`: kotlin.String): kotlin.String + + fun `hasLabels`(): kotlin.Boolean + + fun `import`(`jsonl`: kotlin.String) + + fun `importLabels`(`labels`: Bip329Labels) + + fun `insertOrUpdateLabelsForTxn`(`details`: TransactionDetails, `label`: kotlin.String, `origin`: kotlin.String?) + + fun `transactionLabel`(`txId`: TxId): kotlin.String? companion object } -open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface +open class LabelManager: Disposable, AutoCloseable, LabelManagerInterface { @Suppress("UNUSED_PARAMETER") @@ -13718,6 +12795,14 @@ open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface this.handle = 0 this.cleanable = null } + constructor(`id`: WalletId) : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_labelmanager_new( + + FfiConverterTypeWalletId.lower(`id`),_status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -13773,7 +12858,7 @@ open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_labelstable(handle, status) + UniffiLib.uniffi_cove_fn_free_labelmanager(handle, status) } } } @@ -13786,11 +12871,116 @@ open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_labelstable(handle, status) + UniffiLib.uniffi_cove_fn_clone_labelmanager(handle, status) } } + @Throws(LabelManagerException::class)override fun `deleteLabelsForTxn`(`txId`: TxId) + = + callWithHandle { + uniffiRustCallWithError(LabelManagerException) { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_delete_labels_for_txn( + it, + FfiConverterTypeTxId.lower(`txId`),_status) +} + } + + + + + @Throws(LabelManagerException::class)override fun `export`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCallWithError(LabelManagerException) { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_export( + it, + _status) +} + } + ) + } + + + override fun `exportDefaultFileName`(`name`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_export_default_file_name( + it, + FfiConverterString.lower(`name`),_status) +} + } + ) + } + + + override fun `hasLabels`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_has_labels( + it, + _status) +} + } + ) + } + + + + @Throws(LabelManagerException::class)override fun `import`(`jsonl`: kotlin.String) + = + callWithHandle { + uniffiRustCallWithError(LabelManagerException) { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_import( + it, + FfiConverterString.lower(`jsonl`),_status) +} + } + + + + + @Throws(LabelManagerException::class)override fun `importLabels`(`labels`: Bip329Labels) + = + callWithHandle { + uniffiRustCallWithError(LabelManagerException) { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_importlabels( + it, + FfiConverterTypeBip329Labels.lower(`labels`),_status) +} + } + + + + + @Throws(LabelManagerException::class)override fun `insertOrUpdateLabelsForTxn`(`details`: TransactionDetails, `label`: kotlin.String, `origin`: kotlin.String?) + = + callWithHandle { + uniffiRustCallWithError(LabelManagerException) { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_insert_or_update_labels_for_txn( + it, + FfiConverterTypeTransactionDetails.lower(`details`),FfiConverterString.lower(`label`),FfiConverterOptionalString.lower(`origin`),_status) +} + } + + + + override fun `transactionLabel`(`txId`: TxId): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_labelmanager_transaction_label( + it, + FfiConverterTypeTxId.lower(`txId`),_status) +} + } + ) + } + + + @@ -13808,22 +12998,22 @@ open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface /** * @suppress */ -public object FfiConverterTypeLabelsTable: FfiConverter { - override fun lower(value: LabelsTable): Long { +public object FfiConverterTypeLabelManager: FfiConverter { + override fun lower(value: LabelManager): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): LabelsTable { - return LabelsTable(UniffiWithHandle, value) + override fun lift(value: Long): LabelManager { + return LabelManager(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): LabelsTable { + override fun read(buf: ByteBuffer): LabelManager { return lift(buf.getLong()) } - override fun allocationSize(value: LabelsTable) = 8UL + override fun allocationSize(value: LabelManager) = 8UL - override fun write(value: LabelsTable, buf: ByteBuffer) { + override fun write(value: LabelManager, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -13924,16 +13114,12 @@ public object FfiConverterTypeLabelsTable: FfiConverter { // -public interface MnemonicInterface { - - fun `allWords`(): List - - fun `words`(): List +public interface LabelsTableInterface { companion object } -open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface +open class LabelsTable: Disposable, AutoCloseable, LabelsTableInterface { @Suppress("UNUSED_PARAMETER") @@ -13957,14 +13143,6 @@ open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface this.handle = 0 this.cleanable = null } - constructor(`id`: WalletId) : - this(UniffiWithHandle, - uniffiRustCallWithError(MnemonicException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_mnemonic_new( - - FfiConverterTypeWalletId.lower(`id`),_status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -14020,7 +13198,7 @@ open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_mnemonic(handle, status) + UniffiLib.uniffi_cove_fn_free_labelstable(handle, status) } } } @@ -14033,56 +13211,21 @@ open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_mnemonic(handle, status) + UniffiLib.uniffi_cove_fn_clone_labelstable(handle, status) } } - override fun `allWords`(): List { - return FfiConverterSequenceTypeGroupedWord.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_mnemonic_all_words( - it, - _status) -} - } - ) - } - - - override fun `words`(): List { - return FfiConverterSequenceString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_mnemonic_words( - it, - _status) -} - } - ) - } - - - companion object { - fun `preview`(`numberOfBip39Words`: NumberOfBip39Words): Mnemonic { - return FfiConverterTypeMnemonic.lift( - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_mnemonic_preview( - - FfiConverterTypeNumberOfBip39Words.lower(`numberOfBip39Words`),_status) -} - ) - } - - - } + /** + * @suppress + */ + companion object } @@ -14090,22 +13233,22 @@ open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface /** * @suppress */ -public object FfiConverterTypeMnemonic: FfiConverter { - override fun lower(value: Mnemonic): Long { +public object FfiConverterTypeLabelsTable: FfiConverter { + override fun lower(value: LabelsTable): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Mnemonic { - return Mnemonic(UniffiWithHandle, value) + override fun lift(value: Long): LabelsTable { + return LabelsTable(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Mnemonic { + override fun read(buf: ByteBuffer): LabelsTable { return lift(buf.getLong()) } - override fun allocationSize(value: Mnemonic) = 8UL + override fun allocationSize(value: LabelsTable) = 8UL - override fun write(value: Mnemonic, buf: ByteBuffer) { + override fun write(value: LabelsTable, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -14206,24 +13349,16 @@ public object FfiConverterTypeMnemonic: FfiConverter { // -public interface MultiQrInterface { - - fun `addPart`(`qr`: kotlin.String): BbqrJoinResult - - fun `getGroupedWords`(`qr`: StringOrData, `groupsOf`: kotlin.UByte): List>? - - fun `handleScanResult`(`qr`: StringOrData): MultiQrScanResult - - fun `isBbqr`(): kotlin.Boolean +public interface MnemonicInterface { - fun `isSeedQr`(): kotlin.Boolean + fun `allWords`(): List - fun `totalParts`(): kotlin.UInt + fun `words`(): List companion object } -open class MultiQr: Disposable, AutoCloseable, MultiQrInterface +open class Mnemonic: Disposable, AutoCloseable, MnemonicInterface { @Suppress("UNUSED_PARAMETER") @@ -14247,6 +13382,14 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface this.handle = 0 this.cleanable = null } + constructor(`id`: WalletId) : + this(UniffiWithHandle, + uniffiRustCallWithError(MnemonicException) { _status -> + UniffiLib.uniffi_cove_fn_constructor_mnemonic_new( + + FfiConverterTypeWalletId.lower(`id`),_status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -14302,7 +13445,7 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_multiqr(handle, status) + UniffiLib.uniffi_cove_fn_free_mnemonic(handle, status) } } } @@ -14315,70 +13458,15 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_multiqr(handle, status) + UniffiLib.uniffi_cove_fn_clone_mnemonic(handle, status) } } - - @Throws(MultiQrException::class)override fun `addPart`(`qr`: kotlin.String): BbqrJoinResult { - return FfiConverterTypeBbqrJoinResult.lift( - callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_add_part( - it, - FfiConverterString.lower(`qr`),_status) -} - } - ) - } - - - - @Throws(MultiQrException::class)override fun `getGroupedWords`(`qr`: StringOrData, `groupsOf`: kotlin.UByte): List>? { - return FfiConverterOptionalSequenceSequenceString.lift( - callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_get_grouped_words( - it, - FfiConverterTypeStringOrData.lower(`qr`),FfiConverterUByte.lower(`groupsOf`),_status) -} - } - ) - } - - - - @Throws(MultiQrException::class)override fun `handleScanResult`(`qr`: StringOrData): MultiQrScanResult { - return FfiConverterTypeMultiQrScanResult.lift( - callWithHandle { - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_handle_scan_result( - it, - FfiConverterTypeStringOrData.lower(`qr`),_status) -} - } - ) - } - - - override fun `isBbqr`(): kotlin.Boolean { - return FfiConverterBoolean.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_is_bbqr( - it, - _status) -} - } - ) - } - - - override fun `isSeedQr`(): kotlin.Boolean { - return FfiConverterBoolean.lift( + override fun `allWords`(): List { + return FfiConverterSequenceTypeGroupedWord.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_is_seed_qr( + UniffiLib.uniffi_cove_fn_method_mnemonic_all_words( it, _status) } @@ -14387,11 +13475,11 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface } - override fun `totalParts`(): kotlin.UInt { - return FfiConverterUInt.lift( + override fun `words`(): List { + return FfiConverterSequenceString.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_multiqr_total_parts( + UniffiLib.uniffi_cove_fn_method_mnemonic_words( it, _status) } @@ -14407,36 +13495,12 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface companion object { - fun `newFromString`(`qr`: kotlin.String): MultiQr { - return FfiConverterTypeMultiQr.lift( + fun `preview`(`numberOfBip39Words`: NumberOfBip39Words): Mnemonic { + return FfiConverterTypeMnemonic.lift( uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_multiqr_new_from_string( - - FfiConverterString.lower(`qr`),_status) -} - ) - } - - - - @Throws(MultiQrException::class) fun `tryNew`(`qr`: StringOrData): MultiQr { - return FfiConverterTypeMultiQr.lift( - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_multiqr_try_new( - - FfiConverterTypeStringOrData.lower(`qr`),_status) -} - ) - } - - - - @Throws(MultiQrException::class) fun `tryNewFromData`(`data`: kotlin.ByteArray): MultiQr { - return FfiConverterTypeMultiQr.lift( - uniffiRustCallWithError(MultiQrException) { _status -> - UniffiLib.uniffi_cove_fn_constructor_multiqr_try_new_from_data( + UniffiLib.uniffi_cove_fn_constructor_mnemonic_preview( - FfiConverterByteArray.lower(`data`),_status) + FfiConverterTypeNumberOfBip39Words.lower(`numberOfBip39Words`),_status) } ) } @@ -14451,22 +13515,22 @@ open class MultiQr: Disposable, AutoCloseable, MultiQrInterface /** * @suppress */ -public object FfiConverterTypeMultiQr: FfiConverter { - override fun lower(value: MultiQr): Long { +public object FfiConverterTypeMnemonic: FfiConverter { + override fun lower(value: Mnemonic): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): MultiQr { - return MultiQr(UniffiWithHandle, value) + override fun lift(value: Long): Mnemonic { + return Mnemonic(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): MultiQr { + override fun read(buf: ByteBuffer): Mnemonic { return lift(buf.getLong()) } - override fun allocationSize(value: MultiQr) = 8UL + override fun allocationSize(value: Mnemonic) = 8UL - override fun write(value: MultiQr, buf: ByteBuffer) { + override fun write(value: Mnemonic, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -15667,6 +14731,317 @@ public object FfiConverterTypePushTx: FfiConverter { // +/** + * FFI wrapper for QrScanner state machine. + * + * This is the main entry point for QR scanning from Swift/Kotlin. + * It wraps the internal state machine in a Mutex for thread safety. + */ +public interface QrScannerInterface { + + /** + * Reset the scanner state for a new scan session. + */ + fun `reset`() + + /** + * Scan a QR code and return the result. + * + * On first scan, detects the format and returns either: + * - `Complete(MultiFormat)` for single-part QRs + * - `InProgress(ScanProgress)` for multi-part QRs (BBQr or UR) + * + * On subsequent scans, adds parts and returns updated status. + * The haptic field indicates what feedback the platform should trigger. + */ + fun `scan`(`qr`: StringOrData): ScanResult + + companion object +} + +/** + * FFI wrapper for QrScanner state machine. + * + * This is the main entry point for QR scanning from Swift/Kotlin. + * It wraps the internal state machine in a Mutex for thread safety. + */ +open class QrScanner: Disposable, AutoCloseable, QrScannerInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + constructor() : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_qrscanner_new( + + _status) +} + ) + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_qrscanner(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_qrscanner(handle, status) + } + } + + + /** + * Reset the scanner state for a new scan session. + */override fun `reset`() + = + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_qrscanner_reset( + it, + _status) +} + } + + + + + /** + * Scan a QR code and return the result. + * + * On first scan, detects the format and returns either: + * - `Complete(MultiFormat)` for single-part QRs + * - `InProgress(ScanProgress)` for multi-part QRs (BBQr or UR) + * + * On subsequent scans, adds parts and returns updated status. + * The haptic field indicates what feedback the platform should trigger. + */ + @Throws(MultiQrException::class)override fun `scan`(`qr`: StringOrData): ScanResult { + return FfiConverterTypeScanResult.lift( + callWithHandle { + uniffiRustCallWithError(MultiQrException) { _status -> + UniffiLib.uniffi_cove_fn_method_qrscanner_scan( + it, + FfiConverterTypeStringOrData.lower(`qr`),_status) +} + } + ) + } + + + + + + + + + + /** + * @suppress + */ + companion object + +} + + +/** + * @suppress + */ +public object FfiConverterTypeQrScanner: FfiConverter { + override fun lower(value: QrScanner): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): QrScanner { + return QrScanner(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): QrScanner { + return lift(buf.getLong()) + } + + override fun allocationSize(value: QrScanner) = 8UL + + override fun write(value: QrScanner, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + public interface RouteFactoryInterface { fun `coinControlSend`(`id`: WalletId, `utxos`: List): Route @@ -21837,7 +21212,324 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_unsignedtransaction(handle, status) + UniffiLib.uniffi_cove_fn_free_unsignedtransaction(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_unsignedtransaction(handle, status) + } + } + + override fun `details`(): ConfirmDetails { + return FfiConverterTypeConfirmDetails.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransaction_details( + it, + _status) +} + } + ) + } + + + override fun `id`(): TxId { + return FfiConverterTypeTxId.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransaction_id( + it, + _status) +} + } + ) + } + + + override fun `label`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransaction_label( + it, + _status) +} + } + ) + } + + + override fun `sendingAmount`(): Amount { + return FfiConverterTypeAmount.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransaction_sending_amount( + it, + _status) +} + } + ) + } + + + override fun `spendingAmount`(): Amount { + return FfiConverterTypeAmount.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransaction_spending_amount( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + fun `previewNew`(): UnsignedTransaction { + return FfiConverterTypeUnsignedTransaction.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_unsignedtransaction_preview_new( + + _status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeUnsignedTransaction: FfiConverter { + override fun lower(value: UnsignedTransaction): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): UnsignedTransaction { + return UnsignedTransaction(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): UnsignedTransaction { + return lift(buf.getLong()) + } + + override fun allocationSize(value: UnsignedTransaction) = 8UL + + override fun write(value: UnsignedTransaction, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface UnsignedTransactionRecordInterface { + + fun `confirmDetails`(): ConfirmDetails + + fun `createdAt`(): kotlin.ULong + + fun `txId`(): TxId + + fun `walletId`(): WalletId + + companion object +} + +open class UnsignedTransactionRecord: Disposable, AutoCloseable, UnsignedTransactionRecordInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_unsignedtransactionrecord(handle, status) } } } @@ -21850,28 +21542,15 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_unsignedtransaction(handle, status) + UniffiLib.uniffi_cove_fn_clone_unsignedtransactionrecord(handle, status) } } - override fun `details`(): ConfirmDetails { + override fun `confirmDetails`(): ConfirmDetails { return FfiConverterTypeConfirmDetails.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransaction_details( - it, - _status) -} - } - ) - } - - - override fun `id`(): TxId { - return FfiConverterTypeTxId.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransaction_id( + UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_confirm_details( it, _status) } @@ -21880,11 +21559,11 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn } - override fun `label`(): kotlin.String { - return FfiConverterString.lift( + override fun `createdAt`(): kotlin.ULong { + return FfiConverterULong.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransaction_label( + UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_created_at( it, _status) } @@ -21893,11 +21572,11 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn } - override fun `sendingAmount`(): Amount { - return FfiConverterTypeAmount.lift( + override fun `txId`(): TxId { + return FfiConverterTypeTxId.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransaction_sending_amount( + UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_tx_id( it, _status) } @@ -21906,11 +21585,11 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn } - override fun `spendingAmount`(): Amount { - return FfiConverterTypeAmount.lift( + override fun `walletId`(): WalletId { + return FfiConverterTypeWalletId.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransaction_spending_amount( + UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_wallet_id( it, _status) } @@ -21925,20 +21604,11 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn - companion object { - fun `previewNew`(): UnsignedTransaction { - return FfiConverterTypeUnsignedTransaction.lift( - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_unsignedtransaction_preview_new( - - _status) -} - ) - } - - - } + /** + * @suppress + */ + companion object } @@ -21946,22 +21616,22 @@ open class UnsignedTransaction: Disposable, AutoCloseable, UnsignedTransactionIn /** * @suppress */ -public object FfiConverterTypeUnsignedTransaction: FfiConverter { - override fun lower(value: UnsignedTransaction): Long { +public object FfiConverterTypeUnsignedTransactionRecord: FfiConverter { + override fun lower(value: UnsignedTransactionRecord): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): UnsignedTransaction { - return UnsignedTransaction(UniffiWithHandle, value) + override fun lift(value: Long): UnsignedTransactionRecord { + return UnsignedTransactionRecord(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): UnsignedTransaction { + override fun read(buf: ByteBuffer): UnsignedTransactionRecord { return lift(buf.getLong()) } - override fun allocationSize(value: UnsignedTransaction) = 8UL + override fun allocationSize(value: UnsignedTransactionRecord) = 8UL - override fun write(value: UnsignedTransaction, buf: ByteBuffer) { + override fun write(value: UnsignedTransactionRecord, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -22062,20 +21732,16 @@ public object FfiConverterTypeUnsignedTransaction: FfiConverter - UniffiLib.uniffi_cove_fn_free_unsignedtransactionrecord(handle, status) + UniffiLib.uniffi_cove_fn_free_unsignedtransactionstable(handle, status) } } } @@ -22167,56 +21833,31 @@ open class UnsignedTransactionRecord: Disposable, AutoCloseable, UnsignedTransac throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_unsignedtransactionrecord(handle, status) + UniffiLib.uniffi_cove_fn_clone_unsignedtransactionstable(handle, status) } } - override fun `confirmDetails`(): ConfirmDetails { - return FfiConverterTypeConfirmDetails.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_confirm_details( - it, - _status) -} - } - ) - } - - - override fun `createdAt`(): kotlin.ULong { - return FfiConverterULong.lift( + override fun `getTx`(`txId`: TxId): UnsignedTransactionRecord? { + return FfiConverterOptionalTypeUnsignedTransactionRecord.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_created_at( + UniffiLib.uniffi_cove_fn_method_unsignedtransactionstable_gettx( it, - _status) + FfiConverterTypeTxId.lower(`txId`),_status) } } ) } - override fun `txId`(): TxId { - return FfiConverterTypeTxId.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_tx_id( - it, - _status) -} - } - ) - } - - override fun `walletId`(): WalletId { - return FfiConverterTypeWalletId.lift( + @Throws(UnsignedTransactionsTableException::class)override fun `getTxThrow`(`txId`: TxId): UnsignedTransactionRecord { + return FfiConverterTypeUnsignedTransactionRecord.lift( callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionrecord_wallet_id( + uniffiRustCallWithError(UnsignedTransactionsTableException) { _status -> + UniffiLib.uniffi_cove_fn_method_unsignedtransactionstable_gettxthrow( it, - _status) + FfiConverterTypeTxId.lower(`txId`),_status) } } ) @@ -22241,22 +21882,22 @@ open class UnsignedTransactionRecord: Disposable, AutoCloseable, UnsignedTransac /** * @suppress */ -public object FfiConverterTypeUnsignedTransactionRecord: FfiConverter { - override fun lower(value: UnsignedTransactionRecord): Long { +public object FfiConverterTypeUnsignedTransactionsTable: FfiConverter { + override fun lower(value: UnsignedTransactionsTable): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): UnsignedTransactionRecord { - return UnsignedTransactionRecord(UniffiWithHandle, value) + override fun lift(value: Long): UnsignedTransactionsTable { + return UnsignedTransactionsTable(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): UnsignedTransactionRecord { + override fun read(buf: ByteBuffer): UnsignedTransactionsTable { return lift(buf.getLong()) } - override fun allocationSize(value: UnsignedTransactionRecord) = 8UL + override fun allocationSize(value: UnsignedTransactionsTable) = 8UL - override fun write(value: UnsignedTransactionRecord, buf: ByteBuffer) { + override fun write(value: UnsignedTransactionsTable, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -22357,16 +21998,28 @@ public object FfiConverterTypeUnsignedTransactionRecord: FfiConverter + UniffiLib.uniffi_cove_fn_constructor_urresult_new( + + FfiConverterByteArray.lower(`data`),FfiConverterTypeUrType.lower(`urType`),_status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -22445,7 +22106,7 @@ open class UnsignedTransactionsTable: Disposable, AutoCloseable, UnsignedTransac return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_unsignedtransactionstable(handle, status) + UniffiLib.uniffi_cove_fn_free_urresult(handle, status) } } } @@ -22458,31 +22119,69 @@ open class UnsignedTransactionsTable: Disposable, AutoCloseable, UnsignedTransac throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_unsignedtransactionstable(handle, status) + UniffiLib.uniffi_cove_fn_clone_urresult(handle, status) } } - override fun `getTx`(`txId`: TxId): UnsignedTransactionRecord? { - return FfiConverterOptionalTypeUnsignedTransactionRecord.lift( + override fun `data`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionstable_gettx( + UniffiLib.uniffi_cove_fn_method_urresult_data( it, - FfiConverterTypeTxId.lower(`txId`),_status) + _status) } } ) } + override fun `isHdkey`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_urresult_is_hdkey( + it, + _status) +} + } + ) + } - @Throws(UnsignedTransactionsTableException::class)override fun `getTxThrow`(`txId`: TxId): UnsignedTransactionRecord { - return FfiConverterTypeUnsignedTransactionRecord.lift( + + override fun `isPsbt`(): kotlin.Boolean { + return FfiConverterBoolean.lift( callWithHandle { - uniffiRustCallWithError(UnsignedTransactionsTableException) { _status -> - UniffiLib.uniffi_cove_fn_method_unsignedtransactionstable_gettxthrow( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_urresult_is_psbt( it, - FfiConverterTypeTxId.lower(`txId`),_status) + _status) +} + } + ) + } + + + override fun `isSeed`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_urresult_is_seed( + it, + _status) +} + } + ) + } + + + override fun `urType`(): UrType { + return FfiConverterTypeUrType.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_urresult_ur_type( + it, + _status) } } ) @@ -22507,22 +22206,22 @@ open class UnsignedTransactionsTable: Disposable, AutoCloseable, UnsignedTransac /** * @suppress */ -public object FfiConverterTypeUnsignedTransactionsTable: FfiConverter { - override fun lower(value: UnsignedTransactionsTable): Long { +public object FfiConverterTypeUrResult: FfiConverter { + override fun lower(value: UrResult): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): UnsignedTransactionsTable { - return UnsignedTransactionsTable(UniffiWithHandle, value) + override fun lift(value: Long): UrResult { + return UrResult(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): UnsignedTransactionsTable { + override fun read(buf: ByteBuffer): UrResult { return lift(buf.getLong()) } - override fun allocationSize(value: UnsignedTransactionsTable) = 8UL + override fun allocationSize(value: UrResult) = 8UL - override fun write(value: UnsignedTransactionsTable, buf: ByteBuffer) { + override fun write(value: UrResult, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -26483,6 +26182,9 @@ sealed class AppStateReconcileMessage: Disposable { object AcceptedTerms : AppStateReconcileMessage() + object WalletsChanged : AppStateReconcileMessage() + + @Suppress("UNNECESSARY_SAFE_CALL") // codegen is much simpler if we unconditionally emit safe calls here @@ -26563,6 +26265,8 @@ sealed class AppStateReconcileMessage: Disposable { } is AppStateReconcileMessage.AcceptedTerms -> {// Nothing to destroy } + is AppStateReconcileMessage.WalletsChanged -> {// Nothing to destroy + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -26613,6 +26317,7 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< FfiConverterTypeRoute.read(buf), ) 12 -> AppStateReconcileMessage.AcceptedTerms + 13 -> AppStateReconcileMessage.WalletsChanged else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -26701,6 +26406,12 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< 4UL ) } + is AppStateReconcileMessage.WalletsChanged -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } } override fun write(value: AppStateReconcileMessage, buf: ByteBuffer) { @@ -26764,6 +26475,10 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< buf.putInt(12) Unit } + is AppStateReconcileMessage.WalletsChanged -> { + buf.putInt(13) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -30459,6 +30174,53 @@ public object FfiConverterTypeGlobalFlagTableError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + HapticFeedback.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: HapticFeedback) = 4UL + + override fun write(value: HapticFeedback, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + sealed class HardwareWalletMetadata: Disposable { data class TapSigner( @@ -31801,6 +31563,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31810,6 +31582,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31819,6 +31601,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31828,6 +31620,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31837,6 +31639,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31849,6 +31661,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31861,6 +31683,16 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -31925,6 +31757,16 @@ sealed class MultiFormat: Disposable { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } companion object } @@ -32081,12 +31923,6 @@ sealed class MultiFormatException: kotlin.Exception() { get() = "" } - class UrFormatNotSupported( - ) : MultiFormatException() { - override val message - get() = "" - } - class InvalidTapSigner( val v1: TapCardParseException @@ -32095,6 +31931,12 @@ sealed class MultiFormatException: kotlin.Exception() { get() = "v1=${ v1 }" } + class TaprootNotSupported( + ) : MultiFormatException() { + override val message + get() = "" + } + @@ -32128,10 +31970,10 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer MultiFormatException.UnsupportedNetworkAddress() 3 -> MultiFormatException.UnrecognizedFormat() - 4 -> MultiFormatException.UrFormatNotSupported() - 5 -> MultiFormatException.InvalidTapSigner( + 4 -> MultiFormatException.InvalidTapSigner( FfiConverterTypeTapCardParseError.read(buf), ) + 5 -> MultiFormatException.TaprootNotSupported() else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -32151,14 +31993,14 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer ( + is MultiFormatException.InvalidTapSigner -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL + + FfiConverterTypeTapCardParseError.allocationSize(value.v1) ) - is MultiFormatException.InvalidTapSigner -> ( + is MultiFormatException.TaprootNotSupported -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL - + FfiConverterTypeTapCardParseError.allocationSize(value.v1) ) } } @@ -32178,13 +32020,13 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer { + is MultiFormatException.InvalidTapSigner -> { buf.putInt(4) + FfiConverterTypeTapCardParseError.write(value.v1, buf) Unit } - is MultiFormatException.InvalidTapSigner -> { + is MultiFormatException.TaprootNotSupported -> { buf.putInt(5) - FfiConverterTypeTapCardParseError.write(value.v1, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -32198,18 +32040,6 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer MultiQrException.CannotAddPartToSingleQr() - 2 -> MultiQrException.CannotAddPartToSeedQr() - 3 -> MultiQrException.ParseException( - FfiConverterString.read(buf), - ) - 4 -> MultiQrException.InvalidUtf8() - 5 -> MultiQrException.NotYetAvailable() - 6 -> MultiQrException.CannotAddBinaryDataToBbqr() - 7 -> MultiQrException.BbqrDidNotContainSeedWords( + 1 -> MultiQrException.ParseException( FfiConverterString.read(buf), ) - 8 -> MultiQrException.InvalidSeedQr( + 2 -> MultiQrException.InvalidUtf8() + 3 -> MultiQrException.RequiresStringData() + 4 -> MultiQrException.InvalidSeedQr( FfiConverterTypeSeedQrError.read(buf), ) - 9 -> MultiQrException.InvalidPlainTextQr( - FfiConverterString.read(buf), + 5 -> MultiQrException.Ur( + FfiConverterTypeUrError.read(buf), ) + 6 -> MultiQrException.BbqrCborNotSupported() else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } override fun allocationSize(value: MultiQrException): ULong { return when(value) { - is MultiQrException.CannotAddPartToSingleQr -> ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - ) - is MultiQrException.CannotAddPartToSeedQr -> ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - ) is MultiQrException.ParseException -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL @@ -32328,74 +32137,56 @@ public object FfiConverterTypeMultiQrError : FfiConverterRustBuffer ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - ) - is MultiQrException.CannotAddBinaryDataToBbqr -> ( + is MultiQrException.RequiresStringData -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL ) - is MultiQrException.BbqrDidNotContainSeedWords -> ( + is MultiQrException.InvalidSeedQr -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL - + FfiConverterString.allocationSize(value.v1) + + FfiConverterTypeSeedQrError.allocationSize(value.v1) ) - is MultiQrException.InvalidSeedQr -> ( + is MultiQrException.Ur -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL - + FfiConverterTypeSeedQrError.allocationSize(value.v1) + + FfiConverterTypeUrError.allocationSize(value.v1) ) - is MultiQrException.InvalidPlainTextQr -> ( + is MultiQrException.BbqrCborNotSupported -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL - + FfiConverterString.allocationSize(value.v1) ) } } override fun write(value: MultiQrException, buf: ByteBuffer) { when(value) { - is MultiQrException.CannotAddPartToSingleQr -> { + is MultiQrException.ParseException -> { buf.putInt(1) + FfiConverterString.write(value.v1, buf) Unit } - is MultiQrException.CannotAddPartToSeedQr -> { + is MultiQrException.InvalidUtf8 -> { buf.putInt(2) Unit } - is MultiQrException.ParseException -> { + is MultiQrException.RequiresStringData -> { buf.putInt(3) - FfiConverterString.write(value.v1, buf) Unit } - is MultiQrException.InvalidUtf8 -> { + is MultiQrException.InvalidSeedQr -> { buf.putInt(4) + FfiConverterTypeSeedQrError.write(value.v1, buf) Unit } - is MultiQrException.NotYetAvailable -> { + is MultiQrException.Ur -> { buf.putInt(5) + FfiConverterTypeUrError.write(value.v1, buf) Unit } - is MultiQrException.CannotAddBinaryDataToBbqr -> { + is MultiQrException.BbqrCborNotSupported -> { buf.putInt(6) Unit } - is MultiQrException.BbqrDidNotContainSeedWords -> { - buf.putInt(7) - FfiConverterString.write(value.v1, buf) - Unit - } - is MultiQrException.InvalidSeedQr -> { - buf.putInt(8) - FfiConverterTypeSeedQrError.write(value.v1, buf) - Unit - } - is MultiQrException.InvalidPlainTextQr -> { - buf.putInt(9) - FfiConverterString.write(value.v1, buf) - Unit - } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -32403,171 +32194,6 @@ public object FfiConverterTypeMultiQrError : FfiConverterRustBuffer { - - Disposable.destroy( - this.v1 - ) - - } - is MultiQrScanResult.Single -> { - - Disposable.destroy( - this.v1 - ) - - } - is MultiQrScanResult.CompletedBBqr -> { - - Disposable.destroy( - this.v1 - ) - - } - is MultiQrScanResult.InProgressBBqr -> { - - Disposable.destroy( - this.v1 - ) - - } - }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } - } - - - - - - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeMultiQrScanResult : FfiConverterRustBuffer{ - override fun read(buf: ByteBuffer): MultiQrScanResult { - return when(buf.getInt()) { - 1 -> MultiQrScanResult.SeedQr( - FfiConverterTypeSeedQr.read(buf), - ) - 2 -> MultiQrScanResult.Single( - FfiConverterString.read(buf), - ) - 3 -> MultiQrScanResult.CompletedBBqr( - FfiConverterTypeBbqrJoined.read(buf), - ) - 4 -> MultiQrScanResult.InProgressBBqr( - FfiConverterUInt.read(buf), - ) - else -> throw RuntimeException("invalid enum value, something is very wrong!!") - } - } - - override fun allocationSize(value: MultiQrScanResult) = when(value) { - is MultiQrScanResult.SeedQr -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterTypeSeedQr.allocationSize(value.v1) - ) - } - is MultiQrScanResult.Single -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterString.allocationSize(value.v1) - ) - } - is MultiQrScanResult.CompletedBBqr -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterTypeBbqrJoined.allocationSize(value.v1) - ) - } - is MultiQrScanResult.InProgressBBqr -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterUInt.allocationSize(value.v1) - ) - } - } - - override fun write(value: MultiQrScanResult, buf: ByteBuffer) { - when(value) { - is MultiQrScanResult.SeedQr -> { - buf.putInt(1) - FfiConverterTypeSeedQr.write(value.v1, buf) - Unit - } - is MultiQrScanResult.Single -> { - buf.putInt(2) - FfiConverterString.write(value.v1, buf) - Unit - } - is MultiQrScanResult.CompletedBBqr -> { - buf.putInt(3) - FfiConverterTypeBbqrJoined.write(value.v1, buf) - Unit - } - is MultiQrScanResult.InProgressBBqr -> { - buf.putInt(4) - FfiConverterUInt.write(value.v1, buf) - Unit - } - }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } - } -} - - - - - sealed class NewWalletRoute { object Select : NewWalletRoute() @@ -33499,6 +33125,256 @@ public object FfiConverterTypeRoute : FfiConverterRustBuffer{ +/** + * Progress information for multi-part QR scans + */ +sealed class ScanProgress { + + /** + * BBQR progress with scanned/total parts + */ + data class Bbqr( + val `scanned`: kotlin.UInt, + val `total`: kotlin.UInt) : ScanProgress() + + { + + + companion object + } + + /** + * UR progress as percentage (0.0 to 1.0) + */ + data class Ur( + val `percentage`: kotlin.Double) : ScanProgress() + + { + + + companion object + } + + + + + + + /** + * Detail text for the progress (e.g., "7 parts left"), or None for UR + */ fun `detailText`(): kotlin.String? { + return FfiConverterOptionalString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_scanprogress_detail_text(FfiConverterTypeScanProgress.lower(this), + _status) +} + ) + } + + + + /** + * Display text for the progress (e.g., "Scanned 3 of 10" or "Scanned 45%") + */ fun `displayText`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_scanprogress_display_text(FfiConverterTypeScanProgress.lower(this), + _status) +} + ) + } + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeScanProgress : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): ScanProgress { + return when(buf.getInt()) { + 1 -> ScanProgress.Bbqr( + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + ) + 2 -> ScanProgress.Ur( + FfiConverterDouble.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: ScanProgress) = when(value) { + is ScanProgress.Bbqr -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterUInt.allocationSize(value.`scanned`) + + FfiConverterUInt.allocationSize(value.`total`) + ) + } + is ScanProgress.Ur -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterDouble.allocationSize(value.`percentage`) + ) + } + } + + override fun write(value: ScanProgress, buf: ByteBuffer) { + when(value) { + is ScanProgress.Bbqr -> { + buf.putInt(1) + FfiConverterUInt.write(value.`scanned`, buf) + FfiConverterUInt.write(value.`total`, buf) + Unit + } + is ScanProgress.Ur -> { + buf.putInt(2) + FfiConverterDouble.write(value.`percentage`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + +/** + * Result of a QR scan - either complete with parsed data or in progress + */ +sealed class ScanResult: Disposable { + + /** + * Scan complete - here's the parsed data + */ + data class Complete( + val `data`: org.bitcoinppl.cove_core.MultiFormat, + /** + * Haptic feedback to trigger + */ + val `haptic`: org.bitcoinppl.cove_core.HapticFeedback) : ScanResult() + + { + + + companion object + } + + /** + * Multi-part scan in progress + */ + data class InProgress( + val `progress`: org.bitcoinppl.cove_core.ScanProgress, + /** + * Haptic feedback to trigger + */ + val `haptic`: org.bitcoinppl.cove_core.HapticFeedback) : ScanResult() + + { + + + companion object + } + + + + @Suppress("UNNECESSARY_SAFE_CALL") // codegen is much simpler if we unconditionally emit safe calls here + override fun destroy() { + when(this) { + is ScanResult.Complete -> { + + Disposable.destroy( + this.`data`, + this.`haptic` + ) + + } + is ScanResult.InProgress -> { + + Disposable.destroy( + this.`progress`, + this.`haptic` + ) + + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeScanResult : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): ScanResult { + return when(buf.getInt()) { + 1 -> ScanResult.Complete( + FfiConverterTypeMultiFormat.read(buf), + FfiConverterTypeHapticFeedback.read(buf), + ) + 2 -> ScanResult.InProgress( + FfiConverterTypeScanProgress.read(buf), + FfiConverterTypeHapticFeedback.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: ScanResult) = when(value) { + is ScanResult.Complete -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeMultiFormat.allocationSize(value.`data`) + + FfiConverterTypeHapticFeedback.allocationSize(value.`haptic`) + ) + } + is ScanResult.InProgress -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeScanProgress.allocationSize(value.`progress`) + + FfiConverterTypeHapticFeedback.allocationSize(value.`haptic`) + ) + } + } + + override fun write(value: ScanResult, buf: ByteBuffer) { + when(value) { + is ScanResult.Complete -> { + buf.putInt(1) + FfiConverterTypeMultiFormat.write(value.`data`, buf) + FfiConverterTypeHapticFeedback.write(value.`haptic`, buf) + Unit + } + is ScanResult.InProgress -> { + buf.putInt(2) + FfiConverterTypeScanProgress.write(value.`progress`, buf) + FfiConverterTypeHapticFeedback.write(value.`haptic`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class ScanState { object NotStarted : ScanState() @@ -38573,6 +38449,173 @@ public object FfiConverterTypeUnsignedTransactionsTableError : FfiConverterRustB +/** + * Supported UR types for Bitcoin operations + */ +sealed class UrType { + + /** + * crypto-psbt - Partially Signed Bitcoin Transaction + */ + object CryptoPsbt : UrType() + + + /** + * crypto-seed - BIP39 seed + */ + object CryptoSeed : UrType() + + + /** + * crypto-hdkey - HD key (xpub/xprv) + */ + object CryptoHdkey : UrType() + + + /** + * crypto-account - Account descriptor + */ + object CryptoAccount : UrType() + + + /** + * crypto-output - Output descriptor + */ + object CryptoOutput : UrType() + + + /** + * bytes - Raw bytes + */ + object Bytes : UrType() + + + /** + * Unknown type with raw string + */ + data class Unknown( + val v1: kotlin.String) : UrType() + + { + + + companion object + } + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeUrType : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): UrType { + return when(buf.getInt()) { + 1 -> UrType.CryptoPsbt + 2 -> UrType.CryptoSeed + 3 -> UrType.CryptoHdkey + 4 -> UrType.CryptoAccount + 5 -> UrType.CryptoOutput + 6 -> UrType.Bytes + 7 -> UrType.Unknown( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: UrType) = when(value) { + is UrType.CryptoPsbt -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.CryptoSeed -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.CryptoHdkey -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.CryptoAccount -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.CryptoOutput -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.Bytes -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is UrType.Unknown -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: UrType, buf: ByteBuffer) { + when(value) { + is UrType.CryptoPsbt -> { + buf.putInt(1) + Unit + } + is UrType.CryptoSeed -> { + buf.putInt(2) + Unit + } + is UrType.CryptoHdkey -> { + buf.putInt(3) + Unit + } + is UrType.CryptoAccount -> { + buf.putInt(4) + Unit + } + is UrType.CryptoOutput -> { + buf.putInt(5) + Unit + } + is UrType.Bytes -> { + buf.putInt(6) + Unit + } + is UrType.Unknown -> { + buf.putInt(7) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + enum class WalletAddressType { @@ -43218,38 +43261,6 @@ public object FfiConverterOptionalSequenceTypeUtxo: FfiConverterRustBuffer>?> { - override fun read(buf: ByteBuffer): List>? { - if (buf.get().toInt() == 0) { - return null - } - return FfiConverterSequenceSequenceString.read(buf) - } - - override fun allocationSize(value: List>?): ULong { - if (value == null) { - return 1UL - } else { - return 1UL + FfiConverterSequenceSequenceString.allocationSize(value) - } - } - - override fun write(value: List>?, buf: ByteBuffer) { - if (value == null) { - buf.put(0) - } else { - buf.put(1) - FfiConverterSequenceSequenceString.write(value, buf) - } - } -} - - - - /** * @suppress */ @@ -43978,6 +43989,19 @@ object AddressExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): UrException = + uniffi.cove_ur.UrException.ErrorHandler.lift( + RustBufferUrError.ByValue().apply { + capacity = error_buf.capacity + len = error_buf.len + data = error_buf.data + } + ) +} + + + 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 77e48b6d7..1a342ce17 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 @@ -637,241 +637,265 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckApiChecksums(this) } external fun uniffi_cove_types_checksum_func_address_is_valid( - ): Short - external fun uniffi_cove_types_checksum_func_address_is_valid_for_network( - ): Short - external fun uniffi_cove_types_checksum_func_address_string_spaced_out( - ): Short - external fun uniffi_cove_types_checksum_func_all_color_schemes( - ): Short - external fun uniffi_cove_types_checksum_func_color_scheme_selection_capitalized_string( - ): Short - external fun uniffi_cove_types_checksum_func_fee_rate_options_with_total_fee_is_equal( - ): Short - external fun uniffi_cove_types_checksum_func_fee_speed_duration( - ): Short - external fun uniffi_cove_types_checksum_func_fee_speed_is_custom( - ): Short - external fun uniffi_cove_types_checksum_func_fee_speed_to_circle_color( - ): Short - external fun uniffi_cove_types_checksum_func_all_networks( - ): Short - external fun uniffi_cove_types_checksum_func_network_to_string( - ): Short - external fun uniffi_cove_types_checksum_func_all_units( - ): Short - external fun uniffi_cove_types_checksum_func_previewnewutxolist( - ): Short - external fun uniffi_cove_types_checksum_func_utxo_date( - ): Short - external fun uniffi_cove_types_checksum_func_utxo_hash_to_uint( - ): Short - external fun uniffi_cove_types_checksum_func_utxo_is_equal( - ): Short - external fun uniffi_cove_types_checksum_func_utxo_name( - ): Short - external fun uniffi_cove_types_checksum_method_address_hashtouint( - ): Short - external fun uniffi_cove_types_checksum_method_address_spaced_out( - ): Short - external fun uniffi_cove_types_checksum_method_address_string( - ): Short - external fun uniffi_cove_types_checksum_method_address_unformatted( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfo_address( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfo_address_unformatted( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfo_index( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address_spaced_out( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address_unformatted( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfowithderivation_derivation_path( - ): Short - external fun uniffi_cove_types_checksum_method_addressinfowithderivation_index( - ): Short - external fun uniffi_cove_types_checksum_method_addresswithnetwork_address( - ): Short - external fun uniffi_cove_types_checksum_method_addresswithnetwork_amount( - ): Short - external fun uniffi_cove_types_checksum_method_addresswithnetwork_isvalidfornetwork( - ): Short - external fun uniffi_cove_types_checksum_method_addresswithnetwork_network( - ): Short - external fun uniffi_cove_types_checksum_method_amount_as_btc( - ): Short - external fun uniffi_cove_types_checksum_method_amount_as_sats( - ): Short - external fun uniffi_cove_types_checksum_method_amount_btc_string( - ): Short - external fun uniffi_cove_types_checksum_method_amount_btc_string_with_unit( - ): Short - external fun uniffi_cove_types_checksum_method_amount_fmt_string( - ): Short - external fun uniffi_cove_types_checksum_method_amount_fmt_string_with_unit( - ): Short - external fun uniffi_cove_types_checksum_method_amount_sats_string( - ): Short - external fun uniffi_cove_types_checksum_method_amount_sats_string_with_unit( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_fee_percentage( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_fee_rate( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_fee_total( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_id( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_id_hash( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_inputs( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_normalized_id( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_outputs( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_psbt( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_bytes( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_hex( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_sending_amount( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_sending_to( - ): Short - external fun uniffi_cove_types_checksum_method_confirmdetails_spending_amount( - ): Short - external fun uniffi_cove_types_checksum_method_feerate_sat_per_vb( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoption_duration( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoption_fee_rate( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoption_fee_speed( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoption_is_equal( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoption_sat_per_vb( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_duration( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_rate( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_rate_options( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_speed( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_is_custom( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_is_equal( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sat_per_vb( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_total_fee( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptions_fast( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptions_medium( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptions_slow( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_add_custom_fee_rate( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_calculate_custom_fee_speed( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_custom( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_fast( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_fee_rate_options( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_get_fee_rate_with( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_medium( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_remove_custom_fee( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_slow( - ): Short - external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_transaction_size( - ): Short - external fun uniffi_cove_types_checksum_method_psbt_fee( - ): Short - external fun uniffi_cove_types_checksum_method_psbt_output_total_amount( - ): Short - external fun uniffi_cove_types_checksum_method_psbt_tx_id( - ): Short - external fun uniffi_cove_types_checksum_method_psbt_weight( - ): Short - external fun uniffi_cove_types_checksum_method_outpoint_eq( - ): Short - external fun uniffi_cove_types_checksum_method_outpoint_hashtouint( - ): Short - external fun uniffi_cove_types_checksum_method_outpoint_txid( - ): Short - external fun uniffi_cove_types_checksum_method_outpoint_txid_str( - ): Short - external fun uniffi_cove_types_checksum_method_outpoint_txn_link( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_amount( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_amount_fmt( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_direction( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_external_sent( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_label( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_received( - ): Short - external fun uniffi_cove_types_checksum_method_sentandreceived_sent( - ): Short - external fun uniffi_cove_types_checksum_method_txid_as_hash_string( - ): Short - external fun uniffi_cove_types_checksum_constructor_address_from_string( - ): Short - external fun uniffi_cove_types_checksum_constructor_address_preview_new( - ): Short - external fun uniffi_cove_types_checksum_constructor_address_random( - ): Short - external fun uniffi_cove_types_checksum_constructor_addresswithnetwork_new( - ): Short - external fun uniffi_cove_types_checksum_constructor_amount_from_sat( - ): Short - external fun uniffi_cove_types_checksum_constructor_amount_one_btc( - ): Short - external fun uniffi_cove_types_checksum_constructor_amount_one_sat( - ): Short - external fun uniffi_cove_types_checksum_constructor_confirmdetails_previewnew( - ): Short - external fun uniffi_cove_types_checksum_constructor_feerate_from_sat_per_vb( - ): Short - external fun uniffi_cove_types_checksum_constructor_feerateoption_new( - ): Short - external fun uniffi_cove_types_checksum_constructor_feerateoptionwithtotalfee_new( - ): Short - external fun uniffi_cove_types_checksum_constructor_feerateoptions_previewnew( - ): Short - external fun uniffi_cove_types_checksum_constructor_feerateoptionswithtotalfee_previewnew( - ): Short - external fun uniffi_cove_types_checksum_constructor_psbt_new( - ): Short - external fun uniffi_cove_types_checksum_constructor_outpoint_previewnew( - ): Short - external fun uniffi_cove_types_checksum_constructor_outpoint_withvout( - ): Short - external fun ffi_cove_types_uniffi_contract_version( - ): Int +): Short +external fun uniffi_cove_types_checksum_func_address_is_valid_for_network( +): Short +external fun uniffi_cove_types_checksum_func_address_string_spaced_out( +): Short +external fun uniffi_cove_types_checksum_func_all_color_schemes( +): Short +external fun uniffi_cove_types_checksum_func_color_scheme_selection_capitalized_string( +): Short +external fun uniffi_cove_types_checksum_func_confirm_details_preview_new( +): Short +external fun uniffi_cove_types_checksum_func_qr_density_is_equal( +): Short +external fun uniffi_cove_types_checksum_func_fee_rate_options_with_total_fee_is_equal( +): Short +external fun uniffi_cove_types_checksum_func_fee_speed_duration( +): Short +external fun uniffi_cove_types_checksum_func_fee_speed_is_custom( +): Short +external fun uniffi_cove_types_checksum_func_fee_speed_to_circle_color( +): Short +external fun uniffi_cove_types_checksum_func_all_networks( +): Short +external fun uniffi_cove_types_checksum_func_network_to_string( +): Short +external fun uniffi_cove_types_checksum_func_all_units( +): Short +external fun uniffi_cove_types_checksum_func_previewnewutxolist( +): Short +external fun uniffi_cove_types_checksum_func_utxo_date( +): Short +external fun uniffi_cove_types_checksum_func_utxo_hash_to_uint( +): Short +external fun uniffi_cove_types_checksum_func_utxo_is_equal( +): Short +external fun uniffi_cove_types_checksum_func_utxo_name( +): Short +external fun uniffi_cove_types_checksum_method_address_hashtouint( +): Short +external fun uniffi_cove_types_checksum_method_address_spaced_out( +): Short +external fun uniffi_cove_types_checksum_method_address_string( +): Short +external fun uniffi_cove_types_checksum_method_address_unformatted( +): Short +external fun uniffi_cove_types_checksum_method_addressinfo_address( +): Short +external fun uniffi_cove_types_checksum_method_addressinfo_address_unformatted( +): Short +external fun uniffi_cove_types_checksum_method_addressinfo_index( +): Short +external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address( +): Short +external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address_spaced_out( +): Short +external fun uniffi_cove_types_checksum_method_addressinfowithderivation_address_unformatted( +): Short +external fun uniffi_cove_types_checksum_method_addressinfowithderivation_derivation_path( +): Short +external fun uniffi_cove_types_checksum_method_addressinfowithderivation_index( +): Short +external fun uniffi_cove_types_checksum_method_addresswithnetwork_address( +): Short +external fun uniffi_cove_types_checksum_method_addresswithnetwork_amount( +): Short +external fun uniffi_cove_types_checksum_method_addresswithnetwork_isvalidfornetwork( +): Short +external fun uniffi_cove_types_checksum_method_addresswithnetwork_network( +): Short +external fun uniffi_cove_types_checksum_method_amount_as_btc( +): Short +external fun uniffi_cove_types_checksum_method_amount_as_sats( +): Short +external fun uniffi_cove_types_checksum_method_amount_btc_string( +): Short +external fun uniffi_cove_types_checksum_method_amount_btc_string_with_unit( +): Short +external fun uniffi_cove_types_checksum_method_amount_fmt_string( +): Short +external fun uniffi_cove_types_checksum_method_amount_fmt_string_with_unit( +): Short +external fun uniffi_cove_types_checksum_method_amount_sats_string( +): Short +external fun uniffi_cove_types_checksum_method_amount_sats_string_with_unit( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_fee_percentage( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_fee_rate( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_fee_total( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_id( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_id_hash( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_inputs( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_normalized_id( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_outputs( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_bytes( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr_with_density( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr_with_max_version( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_hex( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_ur( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_psbt_to_ur_with_density( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_sending_amount( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_sending_to( +): Short +external fun uniffi_cove_types_checksum_method_confirmdetails_spending_amount( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_bbqr_max_version( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_can_decrease( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_can_increase( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_decrease( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_increase( +): Short +external fun uniffi_cove_types_checksum_method_qrdensity_ur_fragment_len( +): Short +external fun uniffi_cove_types_checksum_method_feerate_sat_per_vb( +): Short +external fun uniffi_cove_types_checksum_method_feerateoption_duration( +): Short +external fun uniffi_cove_types_checksum_method_feerateoption_fee_rate( +): Short +external fun uniffi_cove_types_checksum_method_feerateoption_fee_speed( +): Short +external fun uniffi_cove_types_checksum_method_feerateoption_is_equal( +): Short +external fun uniffi_cove_types_checksum_method_feerateoption_sat_per_vb( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_duration( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_rate( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_rate_options( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_fee_speed( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_is_custom( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_is_equal( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_sat_per_vb( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionwithtotalfee_total_fee( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptions_fast( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptions_medium( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptions_slow( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_add_custom_fee_rate( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_calculate_custom_fee_speed( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_custom( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_fast( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_fee_rate_options( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_get_fee_rate_with( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_medium( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_remove_custom_fee( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_slow( +): Short +external fun uniffi_cove_types_checksum_method_feerateoptionswithtotalfee_transaction_size( +): Short +external fun uniffi_cove_types_checksum_method_psbt_fee( +): Short +external fun uniffi_cove_types_checksum_method_psbt_output_total_amount( +): Short +external fun uniffi_cove_types_checksum_method_psbt_tx_id( +): Short +external fun uniffi_cove_types_checksum_method_psbt_weight( +): Short +external fun uniffi_cove_types_checksum_method_outpoint_eq( +): Short +external fun uniffi_cove_types_checksum_method_outpoint_hashtouint( +): Short +external fun uniffi_cove_types_checksum_method_outpoint_txid( +): Short +external fun uniffi_cove_types_checksum_method_outpoint_txid_str( +): Short +external fun uniffi_cove_types_checksum_method_outpoint_txn_link( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_amount( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_amount_fmt( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_direction( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_external_sent( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_label( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_received( +): Short +external fun uniffi_cove_types_checksum_method_sentandreceived_sent( +): Short +external fun uniffi_cove_types_checksum_method_txid_as_hash_string( +): Short +external fun uniffi_cove_types_checksum_constructor_address_from_string( +): Short +external fun uniffi_cove_types_checksum_constructor_address_preview_new( +): Short +external fun uniffi_cove_types_checksum_constructor_address_random( +): Short +external fun uniffi_cove_types_checksum_constructor_addresswithnetwork_new( +): Short +external fun uniffi_cove_types_checksum_constructor_amount_from_sat( +): Short +external fun uniffi_cove_types_checksum_constructor_amount_one_btc( +): Short +external fun uniffi_cove_types_checksum_constructor_amount_one_sat( +): Short +external fun uniffi_cove_types_checksum_constructor_qrdensity_new( +): Short +external fun uniffi_cove_types_checksum_constructor_feerate_from_sat_per_vb( +): Short +external fun uniffi_cove_types_checksum_constructor_feerateoption_new( +): Short +external fun uniffi_cove_types_checksum_constructor_feerateoptionwithtotalfee_new( +): Short +external fun uniffi_cove_types_checksum_constructor_feerateoptions_previewnew( +): Short +external fun uniffi_cove_types_checksum_constructor_feerateoptionswithtotalfee_previewnew( +): Short +external fun uniffi_cove_types_checksum_constructor_psbt_new( +): Short +external fun uniffi_cove_types_checksum_constructor_outpoint_previewnew( +): Short +external fun uniffi_cove_types_checksum_constructor_outpoint_withvout( +): Short +external fun ffi_cove_types_uniffi_contract_version( +): Int + - } internal object UniffiLib { @@ -982,8 +1006,6 @@ external fun uniffi_cove_types_fn_clone_confirmdetails(`handle`: Long,uniffi_out ): Long external fun uniffi_cove_types_fn_free_confirmdetails(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit -external fun uniffi_cove_types_fn_constructor_confirmdetails_previewnew(`amount`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long external fun uniffi_cove_types_fn_method_confirmdetails_fee_percentage(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_types_fn_method_confirmdetails_fee_rate(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1006,8 +1028,16 @@ external fun uniffi_cove_types_fn_method_confirmdetails_psbt_bytes(`ptr`: Long,u ): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_bbqr(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_bbqr_with_density(`ptr`: Long,`density`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_bbqr_with_max_version(`ptr`: Long,`maxVersion`: Byte,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_hex(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_ur(`ptr`: Long,`maxFragmentLen`: Int,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +external fun uniffi_cove_types_fn_method_confirmdetails_psbt_to_ur_with_density(`ptr`: Long,`density`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_confirmdetails_sending_amount(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_types_fn_method_confirmdetails_sending_to(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1018,6 +1048,24 @@ external fun uniffi_cove_types_fn_clone_inputoutputdetails(`handle`: Long,uniffi ): Long external fun uniffi_cove_types_fn_free_inputoutputdetails(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit +external fun uniffi_cove_types_fn_clone_qrdensity(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_types_fn_free_qrdensity(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit +external fun uniffi_cove_types_fn_constructor_qrdensity_new(uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_types_fn_method_qrdensity_bbqr_max_version(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_types_fn_method_qrdensity_can_decrease(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_types_fn_method_qrdensity_can_increase(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +external fun uniffi_cove_types_fn_method_qrdensity_decrease(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_types_fn_method_qrdensity_increase(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_types_fn_method_qrdensity_ur_fragment_len(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, +): Int external fun uniffi_cove_types_fn_clone_feerate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_types_fn_free_feerate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1180,6 +1228,8 @@ external fun uniffi_cove_types_fn_clone_utxolist(`handle`: Long,uniffi_out_err: ): Long external fun uniffi_cove_types_fn_free_utxolist(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit +external fun uniffi_cove_types_fn_method_qrexportformat_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_feespeed_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_types_fn_method_network_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1202,6 +1252,10 @@ external fun uniffi_cove_types_fn_func_all_color_schemes(uniffi_out_err: UniffiR ): RustBuffer.ByValue external fun uniffi_cove_types_fn_func_color_scheme_selection_capitalized_string(`colorScheme`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_types_fn_func_confirm_details_preview_new(uniffi_out_err: UniffiRustCallStatus, +): Long +external fun uniffi_cove_types_fn_func_qr_density_is_equal(`lhs`: Long,`rhs`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte external fun uniffi_cove_types_fn_func_fee_rate_options_with_total_fee_is_equal(`lhs`: Long,`rhs`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte external fun uniffi_cove_types_fn_func_fee_speed_duration(`feeSpeed`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1360,6 +1414,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_func_color_scheme_selection_capitalized_string() != 30731.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_func_confirm_details_preview_new() != 21539.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_func_qr_density_is_equal() != 3265.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_func_fee_rate_options_with_total_fee_is_equal() != 17627.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1501,9 +1561,21 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr() != 44475.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr_with_density() != 46679.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_bbqr_with_max_version() != 52826.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_hex() != 28844.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_ur() != 51534.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_confirmdetails_psbt_to_ur_with_density() != 61309.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_types_checksum_method_confirmdetails_sending_amount() != 32253.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1513,6 +1585,24 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_method_confirmdetails_spending_amount() != 58334.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_types_checksum_method_qrdensity_bbqr_max_version() != 14183.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_qrdensity_can_decrease() != 33353.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_qrdensity_can_increase() != 15365.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_qrdensity_decrease() != 5016.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_qrdensity_increase() != 45150.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_types_checksum_method_qrdensity_ur_fragment_len() != 56660.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } 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") } @@ -1666,7 +1756,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_types_checksum_constructor_amount_one_sat() != 27159.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_types_checksum_constructor_confirmdetails_previewnew() != 27324.toShort()) { + if (lib.uniffi_cove_types_checksum_constructor_qrdensity_new() != 57563.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_types_checksum_constructor_feerate_from_sat_per_vb() != 23120.toShort()) { @@ -4114,8 +4204,25 @@ public interface ConfirmDetailsInterface { fun `psbtToBbqr`(): List + /** + * Export PSBT as BBQr with specified max version + */ + fun `psbtToBbqrWithDensity`(`density`: QrDensity): List + + fun `psbtToBbqrWithMaxVersion`(`maxVersion`: kotlin.UByte): List + fun `psbtToHex`(): kotlin.String + /** + * Export PSBT as UR-encoded QR strings for animated display + */ + fun `psbtToUr`(`maxFragmentLen`: kotlin.UInt): List + + /** + * Export PSBT as UR with specified density + */ + fun `psbtToUrWithDensity`(`density`: QrDensity): List + fun `sendingAmount`(): Amount fun `sendingTo`(): Address @@ -4365,6 +4472,37 @@ open class ConfirmDetails: Disposable, AutoCloseable, ConfirmDetailsInterface } + + /** + * Export PSBT as BBQr with specified max version + */ + @Throws(ConfirmDetailsException::class)override fun `psbtToBbqrWithDensity`(`density`: QrDensity): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCallWithError(ConfirmDetailsException) { _status -> + UniffiLib.uniffi_cove_types_fn_method_confirmdetails_psbt_to_bbqr_with_density( + it, + FfiConverterTypeQrDensity.lower(`density`),_status) +} + } + ) + } + + + + @Throws(ConfirmDetailsException::class)override fun `psbtToBbqrWithMaxVersion`(`maxVersion`: kotlin.UByte): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCallWithError(ConfirmDetailsException) { _status -> + UniffiLib.uniffi_cove_types_fn_method_confirmdetails_psbt_to_bbqr_with_max_version( + it, + FfiConverterUByte.lower(`maxVersion`),_status) +} + } + ) + } + + override fun `psbtToHex`(): kotlin.String { return FfiConverterString.lift( callWithHandle { @@ -4378,6 +4516,40 @@ open class ConfirmDetails: Disposable, AutoCloseable, ConfirmDetailsInterface } + + /** + * Export PSBT as UR-encoded QR strings for animated display + */ + @Throws(ConfirmDetailsException::class)override fun `psbtToUr`(`maxFragmentLen`: kotlin.UInt): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCallWithError(ConfirmDetailsException) { _status -> + UniffiLib.uniffi_cove_types_fn_method_confirmdetails_psbt_to_ur( + it, + FfiConverterUInt.lower(`maxFragmentLen`),_status) +} + } + ) + } + + + + /** + * Export PSBT as UR with specified density + */ + @Throws(ConfirmDetailsException::class)override fun `psbtToUrWithDensity`(`density`: QrDensity): List { + return FfiConverterSequenceString.lift( + callWithHandle { + uniffiRustCallWithError(ConfirmDetailsException) { _status -> + UniffiLib.uniffi_cove_types_fn_method_confirmdetails_psbt_to_ur_with_density( + it, + FfiConverterTypeQrDensity.lower(`density`),_status) +} + } + ) + } + + override fun `sendingAmount`(): Amount { return FfiConverterTypeAmount.lift( callWithHandle { @@ -4423,20 +4595,11 @@ open class ConfirmDetails: Disposable, AutoCloseable, ConfirmDetailsInterface - companion object { - fun `previewNew`(`amount`: kotlin.ULong = 20448uL): ConfirmDetails { - return FfiConverterTypeConfirmDetails.lift( - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_types_fn_constructor_confirmdetails_previewnew( - - FfiConverterULong.lower(`amount`),_status) -} - ) - } - - - } + /** + * @suppress + */ + companion object } @@ -7076,15 +7239,374 @@ public object FfiConverterTypePsbt: FfiConverter { // -public interface SentAndReceivedInterface { +/** + * QR code density settings for export + * + * Controls how much data is packed into each QR code frame. + * Higher density = larger/more complex QRs, fewer animation frames. + * Lower density = smaller/simpler QRs, more animation frames. + */ +public interface QrDensityInterface { - fun `amount`(): Amount + fun `bbqrMaxVersion`(): kotlin.UByte - fun `amountFmt`(`unit`: BitcoinUnit): kotlin.String + fun `canDecrease`(): kotlin.Boolean - fun `direction`(): TransactionDirection + fun `canIncrease`(): kotlin.Boolean - fun `externalSent`(): Amount + /** + * Decrease density (smaller QRs, more animation frames) + */ + fun `decrease`(): QrDensity + + /** + * Increase density (larger QRs, fewer animation frames) + */ + fun `increase`(): QrDensity + + fun `urFragmentLen`(): kotlin.UInt + + companion object +} + +/** + * QR code density settings for export + * + * Controls how much data is packed into each QR code frame. + * Higher density = larger/more complex QRs, fewer animation frames. + * Lower density = smaller/simpler QRs, more animation frames. + */ +open class QrDensity: Disposable, AutoCloseable, QrDensityInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + constructor() : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_constructor_qrdensity_new( + + _status) +} + ) + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_types_fn_free_qrdensity(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_types_fn_clone_qrdensity(handle, status) + } + } + + override fun `bbqrMaxVersion`(): kotlin.UByte { + return FfiConverterUByte.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_bbqr_max_version( + it, + _status) +} + } + ) + } + + + override fun `canDecrease`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_can_decrease( + it, + _status) +} + } + ) + } + + + override fun `canIncrease`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_can_increase( + it, + _status) +} + } + ) + } + + + + /** + * Decrease density (smaller QRs, more animation frames) + */override fun `decrease`(): QrDensity { + return FfiConverterTypeQrDensity.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_decrease( + it, + _status) +} + } + ) + } + + + + /** + * Increase density (larger QRs, fewer animation frames) + */override fun `increase`(): QrDensity { + return FfiConverterTypeQrDensity.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_increase( + it, + _status) +} + } + ) + } + + + override fun `urFragmentLen`(): kotlin.UInt { + return FfiConverterUInt.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrdensity_ur_fragment_len( + it, + _status) +} + } + ) + } + + + + + + + + + + /** + * @suppress + */ + companion object + +} + + +/** + * @suppress + */ +public object FfiConverterTypeQrDensity: FfiConverter { + override fun lower(value: QrDensity): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): QrDensity { + return QrDensity(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): QrDensity { + return lift(buf.getLong()) + } + + override fun allocationSize(value: QrDensity) = 8UL + + override fun write(value: QrDensity, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface SentAndReceivedInterface { + + fun `amount`(): Amount + + fun `amountFmt`(`unit`: BitcoinUnit): kotlin.String + + fun `direction`(): TransactionDirection + + fun `externalSent`(): Amount fun `label`(): kotlin.String @@ -9574,6 +10096,58 @@ public object FfiConverterTypePsbtError : FfiConverterRustBuffer +/** + * QR code export format for PSBTs + */ + +enum class QrExportFormat { + + /** + * BBQr format (Binary Bitcoin QR) + */ + BBQR, + /** + * UR format (Uniform Resources) + */ + UR; + + + + // The local Rust `Display`/`Debug` implementation. + override fun toString(): String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_method_qrexportformat_uniffi_trait_display(FfiConverterTypeQrExportFormat.lower(this), + _status) +} + ) + } + + companion object +} + + +/** + * @suppress + */ +public object FfiConverterTypeQrExportFormat: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + QrExportFormat.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: QrExportFormat) = 4UL + + override fun write(value: QrExportFormat, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + enum class TransactionDirection { @@ -10004,6 +10578,32 @@ public typealias FfiConverterTypeWalletId = FfiConverterString ) } + + /** + * Preview ConfirmDetails for SwiftUI previews + */ fun `confirmDetailsPreviewNew`(): ConfirmDetails { + return FfiConverterTypeConfirmDetails.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_func_confirm_details_preview_new( + + _status) +} + ) + } + + + /** + * Check if two QrDensity values are equal (for Swift Equatable conformance) + */ fun `qrDensityIsEqual`(`lhs`: QrDensity, `rhs`: QrDensity): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_types_fn_func_qr_density_is_equal( + + FfiConverterTypeQrDensity.lower(`lhs`),FfiConverterTypeQrDensity.lower(`rhs`),_status) +} + ) + } + fun `feeRateOptionsWithTotalFeeIsEqual`(`lhs`: FeeRateOptionsWithTotalFee, `rhs`: FeeRateOptionsWithTotalFee): kotlin.Boolean { return FfiConverterBoolean.lift( uniffiRustCall() { _status -> diff --git a/android/app/src/main/java/uniffi/cove_ur/cove_ur.kt b/android/app/src/main/java/uniffi/cove_ur/cove_ur.kt new file mode 100644 index 000000000..ac0208668 --- /dev/null +++ b/android/app/src/main/java/uniffi/cove_ur/cove_ur.kt @@ -0,0 +1,2546 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +@file:Suppress("NAME_SHADOWING") + +package uniffi.cove_ur + +// Common helper code. +// +// Ideally this would live in a separate .kt file where it can be unittested etc +// in isolation, and perhaps even published as a re-useable package. +// +// However, it's important that the details of how this helper code works (e.g. the +// way that different builtin types are passed across the FFI) exactly match what's +// expected by the Rust code on the other side of the interface. In practice right +// now that means coming from the exact some version of `uniffi` that was used to +// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin +// helpers directly inline like we're doing here. + +import com.sun.jna.Library +import com.sun.jna.IntegerType +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Callback +import com.sun.jna.ptr.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +// This is a helper for safely working with byte buffers returned from the Rust code. +// A rust-owned buffer is represented by its capacity, its current length, and a +// pointer to the underlying data. + +/** + * @suppress + */ +@Structure.FieldOrder("capacity", "len", "data") +open class RustBuffer : Structure() { + // Note: `capacity` and `len` are actually `ULong` values, but JVM only supports signed values. + // When dealing with these fields, make sure to call `toULong()`. + @JvmField var capacity: Long = 0 + @JvmField var len: Long = 0 + @JvmField var data: Pointer? = null + + class ByValue: RustBuffer(), Structure.ByValue + class ByReference: RustBuffer(), Structure.ByReference + + internal fun setValue(other: RustBuffer) { + capacity = other.capacity + len = other.len + data = other.data + } + + companion object { + internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + // Note: need to convert the size to a `Long` value to make this work with JVM. + UniffiLib.ffi_cove_ur_rustbuffer_alloc(size.toLong(), status) + }.also { + if(it.data == null) { + throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") + } + } + + internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { + var buf = RustBuffer.ByValue() + buf.capacity = capacity.toLong() + buf.len = len.toLong() + buf.data = data + return buf + } + + internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + UniffiLib.ffi_cove_ur_rustbuffer_free(buf, status) + } + } + + @Suppress("TooGenericExceptionThrown") + fun asByteBuffer() = + this.data?.getByteBuffer(0, this.len.toLong())?.also { + it.order(ByteOrder.BIG_ENDIAN) + } +} + +// This is a helper for safely passing byte references into the rust code. +// It's not actually used at the moment, because there aren't many things that you +// can take a direct pointer to in the JVM, and if we're going to copy something +// then we might as well copy it into a `RustBuffer`. But it's here for API +// completeness. + +@Structure.FieldOrder("len", "data") +internal open class ForeignBytes : Structure() { + @JvmField var len: Int = 0 + @JvmField var data: Pointer? = null + + class ByValue : ForeignBytes(), Structure.ByValue +} +/** + * The FfiConverter interface handles converter types to and from the FFI + * + * All implementing objects should be public to support external types. When a + * type is external we need to import it's FfiConverter. + * + * @suppress + */ +public interface FfiConverter { + // Convert an FFI type to a Kotlin type + fun lift(value: FfiType): KotlinType + + // Convert an Kotlin type to an FFI type + fun lower(value: KotlinType): FfiType + + // Read a Kotlin type from a `ByteBuffer` + fun read(buf: ByteBuffer): KotlinType + + // Calculate bytes to allocate when creating a `RustBuffer` + // + // This must return at least as many bytes as the write() function will + // write. It can return more bytes than needed, for example when writing + // Strings we can't know the exact bytes needed until we the UTF-8 + // encoding, so we pessimistically allocate the largest size possible (3 + // bytes per codepoint). Allocating extra bytes is not really a big deal + // because the `RustBuffer` is short-lived. + fun allocationSize(value: KotlinType): ULong + + // Write a Kotlin type to a `ByteBuffer` + fun write(value: KotlinType, buf: ByteBuffer) + + // Lower a value into a `RustBuffer` + // + // This method lowers a value into a `RustBuffer` rather than the normal + // FfiType. It's used by the callback interface code. Callback interface + // returns are always serialized into a `RustBuffer` regardless of their + // normal FFI type. + fun lowerIntoRustBuffer(value: KotlinType): RustBuffer.ByValue { + val rbuf = RustBuffer.alloc(allocationSize(value)) + try { + val bbuf = rbuf.data!!.getByteBuffer(0, rbuf.capacity).also { + it.order(ByteOrder.BIG_ENDIAN) + } + write(value, bbuf) + rbuf.writeField("len", bbuf.position().toLong()) + return rbuf + } catch (e: Throwable) { + RustBuffer.free(rbuf) + throw e + } + } + + // Lift a value from a `RustBuffer`. + // + // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. + // It's currently only used by the `FfiConverterRustBuffer` class below. + fun liftFromRustBuffer(rbuf: RustBuffer.ByValue): KotlinType { + val byteBuf = rbuf.asByteBuffer()!! + try { + val item = read(byteBuf) + if (byteBuf.hasRemaining()) { + throw RuntimeException("junk remaining in buffer after lifting, something is very wrong!!") + } + return item + } finally { + RustBuffer.free(rbuf) + } + } +} + +/** + * FfiConverter that uses `RustBuffer` as the FfiType + * + * @suppress + */ +public interface FfiConverterRustBuffer: FfiConverter { + override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) + override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) +} +// A handful of classes and functions to support the generated data structures. +// This would be a good candidate for isolating in its own ffi-support lib. + +internal const val UNIFFI_CALL_SUCCESS = 0.toByte() +internal const val UNIFFI_CALL_ERROR = 1.toByte() +internal const val UNIFFI_CALL_UNEXPECTED_ERROR = 2.toByte() + +@Structure.FieldOrder("code", "error_buf") +internal open class UniffiRustCallStatus : Structure() { + @JvmField var code: Byte = 0 + @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() + + class ByValue: UniffiRustCallStatus(), Structure.ByValue + + fun isSuccess(): Boolean { + return code == UNIFFI_CALL_SUCCESS + } + + fun isError(): Boolean { + return code == UNIFFI_CALL_ERROR + } + + fun isPanic(): Boolean { + return code == UNIFFI_CALL_UNEXPECTED_ERROR + } + + companion object { + fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { + val callStatus = UniffiRustCallStatus.ByValue() + callStatus.code = code + callStatus.error_buf = errorBuf + return callStatus + } + } +} + +class InternalException(message: String) : kotlin.Exception(message) + +/** + * Each top-level error class has a companion object that can lift the error from the call status's rust buffer + * + * @suppress + */ +interface UniffiRustCallStatusErrorHandler { + fun lift(error_buf: RustBuffer.ByValue): E; +} + +// Helpers for calling Rust +// In practice we usually need to be synchronized to call this safely, so it doesn't +// synchronize itself + +// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { + var status = UniffiRustCallStatus() + val return_value = callback(status) + uniffiCheckCallStatus(errorHandler, status) + return return_value +} + +// Check UniffiRustCallStatus and throw an error if the call wasn't successful +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { + if (status.isSuccess()) { + return + } else if (status.isError()) { + throw errorHandler.lift(status.error_buf) + } else if (status.isPanic()) { + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if (status.error_buf.len > 0) { + throw InternalException(FfiConverterString.lift(status.error_buf)) + } else { + throw InternalException("Rust panic") + } + } else { + throw InternalException("Unknown rust call status: $status.code") + } +} + +/** + * UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR + * + * @suppress + */ +object UniffiNullRustCallStatusErrorHandler: UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): InternalException { + RustBuffer.free(error_buf) + return InternalException("Unexpected CALL_ERROR") + } +} + +// Call a rust function that returns a plain value +private inline fun uniffiRustCall(callback: (UniffiRustCallStatus) -> U): U { + return uniffiRustCallWithError(UniffiNullRustCallStatusErrorHandler, callback) +} + +internal inline fun uniffiTraitInterfaceCall( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(err) + } +} + +internal inline fun uniffiTraitInterfaceCallWithError( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, + lowerError: (E) -> RustBuffer.ByValue +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + if (e is E) { + callStatus.code = UNIFFI_CALL_ERROR + callStatus.error_buf = lowerError(e) + } else { + val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(err) + } + } +} +// Initial value and increment amount for handles. +// These ensure that Kotlin-generated handles always have the lowest bit set +private const val UNIFFI_HANDLEMAP_INITIAL = 1.toLong() +private const val UNIFFI_HANDLEMAP_DELTA = 2.toLong() + +// Map handles to objects +// +// This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. +internal class UniffiHandleMap { + private val map = ConcurrentHashMap() + // Start + private val counter = java.util.concurrent.atomic.AtomicLong(UNIFFI_HANDLEMAP_INITIAL) + + val size: Int + get() = map.size + + // Insert a new object into the handle map and get a handle for it + fun insert(obj: T): Long { + val handle = counter.getAndAdd(UNIFFI_HANDLEMAP_DELTA) + map.put(handle, obj) + return handle + } + + // Clone a handle, creating a new one + fun clone(handle: Long): Long { + val obj = map.get(handle) ?: throw InternalException("UniffiHandleMap.clone: Invalid handle") + return insert(obj) + } + + // Get an object from the handle map + fun get(handle: Long): T { + return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") + } + + // Remove an entry from the handlemap and get the Kotlin object back + fun remove(handle: Long): T { + return map.remove(handle) ?: throw InternalException("UniffiHandleMap: Invalid handle") + } +} + +// Contains loading, initialization code, +// and the FFI Function declarations in a com.sun.jna.Library. +@Synchronized +private fun findLibraryName(componentName: String): String { + val libOverride = System.getProperty("uniffi.component.$componentName.libraryOverride") + if (libOverride != null) { + return libOverride + } + return "cove" +} + +// Define FFI callback types +internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { + fun callback(`data`: Long,`pollResult`: Byte,) +} +internal interface UniffiForeignFutureDroppedCallback : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceClone : com.sun.jna.Callback { + fun callback(`handle`: Long,) + : Long +} +@Structure.FieldOrder("handle", "free") +internal open class UniffiForeignFutureDroppedCallbackStruct( + @JvmField internal var `handle`: Long = 0.toLong(), + @JvmField internal var `free`: UniffiForeignFutureDroppedCallback? = null, +) : Structure() { + class UniffiByValue( + `handle`: Long = 0.toLong(), + `free`: UniffiForeignFutureDroppedCallback? = null, + ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { + `handle` = other.`handle` + `free` = other.`free` + } + +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultF32( + @JvmField internal var `returnValue`: Float = 0.0f, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Float = 0.0f, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultF64( + @JvmField internal var `returnValue`: Double = 0.0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Double = 0.0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultRustBuffer( + @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultRustBuffer.UniffiByValue,) +} +@Structure.FieldOrder("callStatus") +internal open class UniffiForeignFutureResultVoid( + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultVoid(`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultVoid.UniffiByValue,) +} + +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. + +// For large crates we prevent `MethodTooLargeException` (see #2340) +// N.B. the name of the extension is very misleading, since it is +// rather `InterfaceTooLargeException`, caused by too many methods +// in the interface for large crates. +// +// By splitting the otherwise huge interface into two parts +// * UniffiLib (this) +// * IntegrityCheckingUniffiLib +// And all checksum methods are put into `IntegrityCheckingUniffiLib` +// we allow for ~2x as many methods in the UniffiLib interface. +// +// Note: above all written when we used JNA's `loadIndirect` etc. +// We now use JNA's "direct mapping" - unclear if same considerations apply exactly. +internal object IntegrityCheckingUniffiLib { + init { + Native.register(IntegrityCheckingUniffiLib::class.java, findLibraryName(componentName = "cove_ur")) + uniffiCheckContractApiVersion(this) + uniffiCheckApiChecksums(this) + } + external fun uniffi_cove_ur_checksum_method_cryptohdkey_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_to_psbt_bytes( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_to_ur( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_entropy( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_creation_date( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_name( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_note( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptohdkey_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_from_psbt_bytes( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_from_ur( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy_with_metadata( + ): Short + external fun ffi_cove_ur_uniffi_contract_version( + ): Int + + +} + +internal object UniffiLib { + + // The Cleaner for the whole library + internal val CLEANER: UniffiCleaner by lazy { + UniffiCleaner.create() + } + + + init { + Native.register(UniffiLib::class.java, findLibraryName(componentName = "cove_ur")) + + } + external fun uniffi_cove_ur_fn_clone_cryptohdkey(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptohdkey(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptohdkey_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptohdkey_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_clone_cryptopsbt(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptopsbt(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_from_psbt_bytes(`psbtBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_from_ur(`ur`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptopsbt_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptopsbt_to_psbt_bytes(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptopsbt_to_ur(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_clone_cryptoseed(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptoseed(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptoseed_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy(`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy_with_metadata(`payload`: RustBuffer.ByValue,`name`: RustBuffer.ByValue,`note`: RustBuffer.ByValue,`creationDate`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptoseed_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_entropy(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_creation_date(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_name(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_note(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_urerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun ffi_cove_ur_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_cove_ur_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_cove_ur_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_cove_ur_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_cove_ur_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_cove_ur_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_cove_ur_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_cove_ur_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_cove_ur_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_f32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_f32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Float + external fun ffi_cove_ur_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_f64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_f64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Double + external fun ffi_cove_ur_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_void(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_void(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + + +} + +private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { + // Get the bindings contract version from our ComponentInterface + val bindings_contract_version = 30 + // Get the scaffolding contract version by calling the into the dylib + val scaffolding_contract_version = lib.ffi_cove_ur_uniffi_contract_version() + if (bindings_contract_version != scaffolding_contract_version) { + throw RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project") + } +} +@Suppress("UNUSED_PARAMETER") +private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if (lib.uniffi_cove_ur_checksum_method_cryptohdkey_encode() != 5721.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_encode() != 11773.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_to_psbt_bytes() != 52904.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_to_ur() != 8360.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_encode() != 48905.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_entropy() != 60295.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_creation_date() != 4217.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_name() != 47847.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_note() != 48679.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptohdkey_decode() != 58745.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_decode() != 57233.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_from_psbt_bytes() != 63629.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_from_ur() != 823.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_decode() != 59203.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy() != 56252.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy_with_metadata() != 24161.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +/** + * @suppress + */ +public fun uniffiEnsureInitialized() { + IntegrityCheckingUniffiLib + // UniffiLib() initialized as objects are used, but we still need to explicitly + // reference it so initialization across crates works as expected. + UniffiLib +} + +// Async support + +// Public interface members begin here. + + +// Interface implemented by anything that can contain an object reference. +// +// Such types expose a `destroy()` method that must be called to cleanly +// dispose of the contained objects. Failure to call this method may result +// in memory leaks. +// +// The easiest way to ensure this method is called is to use the `.use` +// helper method to execute a block and destroy the object at the end. +interface Disposable { + fun destroy() + companion object { + fun destroy(vararg args: Any?) { + for (arg in args) { + when (arg) { + is Disposable -> arg.destroy() + is ArrayList<*> -> { + for (idx in arg.indices) { + val element = arg[idx] + if (element is Disposable) { + element.destroy() + } + } + } + is Map<*, *> -> { + for (element in arg.values) { + if (element is Disposable) { + element.destroy() + } + } + } + is Iterable<*> -> { + for (element in arg) { + if (element is Disposable) { + element.destroy() + } + } + } + } + } + } + } +} + +/** + * @suppress + */ +inline fun T.use(block: (T) -> R) = + try { + block(this) + } finally { + try { + // N.B. our implementation is on the nullable type `Disposable?`. + this?.destroy() + } catch (e: Throwable) { + // swallow + } + } + +/** + * Placeholder object used to signal that we're constructing an interface with a FFI handle. + * + * This is the first argument for interface constructors that input a raw handle. It exists is that + * so we can avoid signature conflicts when an interface has a regular constructor than inputs a + * Long. + * + * @suppress + * */ +object UniffiWithHandle + +/** + * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. + * + * @suppress + * */ +object NoHandle +/** + * The cleaner interface for Object finalization code to run. + * This is the entry point to any implementation that we're using. + * + * The cleaner registers objects and returns cleanables, so now we are + * defining a `UniffiCleaner` with a `UniffiClenaer.Cleanable` to abstract the + * different implmentations available at compile time. + * + * @suppress + */ +interface UniffiCleaner { + interface Cleanable { + fun clean() + } + + fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable + + companion object +} + +// The fallback Jna cleaner, which is available for both Android, and the JVM. +private class UniffiJnaCleaner : UniffiCleaner { + private val cleaner = com.sun.jna.internal.Cleaner.getCleaner() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + UniffiJnaCleanable(cleaner.register(value, cleanUpTask)) +} + +private class UniffiJnaCleanable( + private val cleanable: com.sun.jna.internal.Cleaner.Cleanable, +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + + +// We decide at uniffi binding generation time whether we were +// using Android or not. +// There are further runtime checks to chose the correct implementation +// of the cleaner. +private fun UniffiCleaner.Companion.create(): UniffiCleaner = + try { + // For safety's sake: if the library hasn't been run in android_cleaner = true + // mode, but is being run on Android, then we still need to think about + // Android API versions. + // So we check if java.lang.ref.Cleaner is there, and use that… + java.lang.Class.forName("java.lang.ref.Cleaner") + JavaLangRefCleaner() + } catch (e: ClassNotFoundException) { + // … otherwise, fallback to the JNA cleaner. + UniffiJnaCleaner() + } + +private class JavaLangRefCleaner : UniffiCleaner { + val cleaner = java.lang.ref.Cleaner.create() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + JavaLangRefCleanable(cleaner.register(value, cleanUpTask)) +} + +private class JavaLangRefCleanable( + val cleanable: java.lang.ref.Cleaner.Cleanable +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + +/** + * @suppress + */ +public object FfiConverterULong: FfiConverter { + override fun lift(value: Long): ULong { + return value.toULong() + } + + override fun read(buf: ByteBuffer): ULong { + return lift(buf.getLong()) + } + + override fun lower(value: ULong): Long { + return value.toLong() + } + + override fun allocationSize(value: ULong) = 8UL + + override fun write(value: ULong, buf: ByteBuffer) { + buf.putLong(value.toLong()) + } +} + +/** + * @suppress + */ +public object FfiConverterString: FfiConverter { + // Note: we don't inherit from FfiConverterRustBuffer, because we use a + // special encoding when lowering/lifting. We can use `RustBuffer.len` to + // store our length and avoid writing it out to the buffer. + override fun lift(value: RustBuffer.ByValue): String { + try { + val byteArr = ByteArray(value.len.toInt()) + value.asByteBuffer()!!.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } finally { + RustBuffer.free(value) + } + } + + override fun read(buf: ByteBuffer): String { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } + + fun toUtf8(value: String): ByteBuffer { + // Make sure we don't have invalid UTF-16, check for lone surrogates. + return Charsets.UTF_8.newEncoder().run { + onMalformedInput(CodingErrorAction.REPORT) + encode(CharBuffer.wrap(value)) + } + } + + override fun lower(value: String): RustBuffer.ByValue { + val byteBuf = toUtf8(value) + // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us + // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. + val rbuf = RustBuffer.alloc(byteBuf.limit().toULong()) + rbuf.asByteBuffer()!!.put(byteBuf) + return rbuf + } + + // We aren't sure exactly how many bytes our string will be once it's UTF-8 + // encoded. Allocate 3 bytes per UTF-16 code unit which will always be + // enough. + override fun allocationSize(value: String): ULong { + val sizeForLength = 4UL + val sizeForString = value.length.toULong() * 3UL + return sizeForLength + sizeForString + } + + override fun write(value: String, buf: ByteBuffer) { + val byteBuf = toUtf8(value) + buf.putInt(byteBuf.limit()) + buf.put(byteBuf) + } +} + +/** + * @suppress + */ +public object FfiConverterByteArray: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ByteArray { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr + } + override fun allocationSize(value: ByteArray): ULong { + return 4UL + value.size.toULong() + } + override fun write(value: ByteArray, buf: ByteBuffer) { + buf.putInt(value.size) + buf.put(value) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-hdkey: Hierarchical Deterministic Key (BIP32) + */ +public interface CryptoHdkeyInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + companion object +} + +/** + * crypto-hdkey: Hierarchical Deterministic Key (BIP32) + */ +open class CryptoHdkey: Disposable, AutoCloseable, CryptoHdkeyInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptohdkey(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptohdkey(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptohdkey_encode( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoHdkey { + return FfiConverterTypeCryptoHdkey.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptohdkey_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoHdkey: FfiConverter { + override fun lower(value: CryptoHdkey): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoHdkey { + return CryptoHdkey(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoHdkey { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoHdkey) = 8UL + + override fun write(value: CryptoHdkey, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-psbt: PSBT encoded as CBOR byte string with tag 310 + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +public interface CryptoPsbtInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + /** + * Get PSBT as bytes + */ + fun `toPsbtBytes`(): kotlin.ByteArray + + /** + * Encode as UR string + */ + fun `toUr`(): kotlin.String + + companion object +} + +/** + * crypto-psbt: PSBT encoded as CBOR byte string with tag 310 + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +open class CryptoPsbt: Disposable, AutoCloseable, CryptoPsbtInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptopsbt(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptopsbt(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_encode( + it, + _status) +} + } + ) + } + + + + /** + * Get PSBT as bytes + */override fun `toPsbtBytes`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_to_psbt_bytes( + it, + _status) +} + } + ) + } + + + + /** + * Encode as UR string + */ + @Throws(UrException::class)override fun `toUr`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_to_ur( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + /** + * Create from PSBT bytes + */ + @Throws(UrException::class) fun `fromPsbtBytes`(`psbtBytes`: kotlin.ByteArray): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_from_psbt_bytes( + + FfiConverterByteArray.lower(`psbtBytes`),_status) +} + ) + } + + + + /** + * Decode from UR string + */ + @Throws(UrException::class) fun `fromUr`(`ur`: kotlin.String): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_from_ur( + + FfiConverterString.lower(`ur`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoPsbt: FfiConverter { + override fun lower(value: CryptoPsbt): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoPsbt { + return CryptoPsbt(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoPsbt { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoPsbt) = 8UL + + override fun write(value: CryptoPsbt, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-seed: BIP39 seed with optional metadata + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +public interface CryptoSeedInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + /** + * Get entropy bytes + */ + fun `entropy`(): kotlin.ByteArray + + /** + * Get creation date if present + */ + fun `getCreationDate`(): kotlin.ULong? + + /** + * Get name if present + */ + fun `getName`(): kotlin.String? + + /** + * Get note if present + */ + fun `getNote`(): kotlin.String? + + companion object +} + +/** + * crypto-seed: BIP39 seed with optional metadata + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +open class CryptoSeed: Disposable, AutoCloseable, CryptoSeedInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptoseed(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptoseed(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_encode( + it, + _status) +} + } + ) + } + + + + /** + * Get entropy bytes + */override fun `entropy`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_entropy( + it, + _status) +} + } + ) + } + + + + /** + * Get creation date if present + */override fun `getCreationDate`(): kotlin.ULong? { + return FfiConverterOptionalULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_creation_date( + it, + _status) +} + } + ) + } + + + + /** + * Get name if present + */override fun `getName`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_name( + it, + _status) +} + } + ) + } + + + + /** + * Get note if present + */override fun `getNote`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_note( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + /** + * Create from entropy bytes + */ + @Throws(UrException::class) fun `fromEntropy`(`payload`: kotlin.ByteArray): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy( + + FfiConverterByteArray.lower(`payload`),_status) +} + ) + } + + + + /** + * Create from entropy bytes with optional metadata + */ + @Throws(UrException::class) fun `fromEntropyWithMetadata`(`payload`: kotlin.ByteArray, `name`: kotlin.String?, `note`: kotlin.String?, `creationDate`: kotlin.ULong?): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy_with_metadata( + + FfiConverterByteArray.lower(`payload`),FfiConverterOptionalString.lower(`name`),FfiConverterOptionalString.lower(`note`),FfiConverterOptionalULong.lower(`creationDate`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoSeed: FfiConverter { + override fun lower(value: CryptoSeed): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoSeed { + return CryptoSeed(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoSeed { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoSeed) = 8UL + + override fun write(value: CryptoSeed, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + + + + +sealed class UrException: kotlin.Exception() { + + class CborEncodeException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class CborDecodeException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidField( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class MissingField( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidKeyDataLength( + + val `expected`: kotlin.ULong, + + val `actual`: kotlin.ULong + ) : UrException() { + override val message + get() = "expected=${ `expected` }, actual=${ `actual` }" + } + + class InvalidTag( + + val `expected`: kotlin.ULong, + + val `actual`: kotlin.ULong + ) : UrException() { + override val message + get() = "expected=${ `expected` }, actual=${ `actual` }" + } + + class InvalidPayloadLength( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class UrParseException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidOperation( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidKeyData( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class MasterKeyNotAllowed( + ) : UrException() { + override val message + get() = "" + } + + class InvalidUtf8( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + + + + // The local Rust `Display`/`Debug` implementation. + override fun toString(): String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_urerror_uniffi_trait_display(FfiConverterTypeUrError.lower(this), + _status) +} + ) + } + + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): UrException = FfiConverterTypeUrError.lift(error_buf) + } + + +} + +/** + * @suppress + */ +public object FfiConverterTypeUrError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UrException { + + + return when(buf.getInt()) { + 1 -> UrException.CborEncodeException( + FfiConverterString.read(buf), + ) + 2 -> UrException.CborDecodeException( + FfiConverterString.read(buf), + ) + 3 -> UrException.InvalidField( + FfiConverterString.read(buf), + ) + 4 -> UrException.MissingField( + FfiConverterString.read(buf), + ) + 5 -> UrException.InvalidKeyDataLength( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + 6 -> UrException.InvalidTag( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + 7 -> UrException.InvalidPayloadLength( + FfiConverterString.read(buf), + ) + 8 -> UrException.UrParseException( + FfiConverterString.read(buf), + ) + 9 -> UrException.InvalidOperation( + FfiConverterString.read(buf), + ) + 10 -> UrException.InvalidKeyData( + FfiConverterString.read(buf), + ) + 11 -> UrException.MasterKeyNotAllowed() + 12 -> UrException.InvalidUtf8( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: UrException): ULong { + return when(value) { + is UrException.CborEncodeException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.CborDecodeException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidField -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.MissingField -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidKeyDataLength -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterULong.allocationSize(value.`expected`) + + FfiConverterULong.allocationSize(value.`actual`) + ) + is UrException.InvalidTag -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterULong.allocationSize(value.`expected`) + + FfiConverterULong.allocationSize(value.`actual`) + ) + is UrException.InvalidPayloadLength -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.UrParseException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidOperation -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidKeyData -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.MasterKeyNotAllowed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is UrException.InvalidUtf8 -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: UrException, buf: ByteBuffer) { + when(value) { + is UrException.CborEncodeException -> { + buf.putInt(1) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.CborDecodeException -> { + buf.putInt(2) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidField -> { + buf.putInt(3) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.MissingField -> { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidKeyDataLength -> { + buf.putInt(5) + FfiConverterULong.write(value.`expected`, buf) + FfiConverterULong.write(value.`actual`, buf) + Unit + } + is UrException.InvalidTag -> { + buf.putInt(6) + FfiConverterULong.write(value.`expected`, buf) + FfiConverterULong.write(value.`actual`, buf) + Unit + } + is UrException.InvalidPayloadLength -> { + buf.putInt(7) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.UrParseException -> { + buf.putInt(8) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidOperation -> { + buf.putInt(9) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidKeyData -> { + buf.putInt(10) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.MasterKeyNotAllowed -> { + buf.putInt(11) + Unit + } + is UrException.InvalidUtf8 -> { + buf.putInt(12) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalULong: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.ULong? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterULong.read(buf) + } + + override fun allocationSize(value: kotlin.ULong?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterULong.allocationSize(value) + } + } + + override fun write(value: kotlin.ULong?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterULong.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalString: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.String? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterString.read(buf) + } + + override fun allocationSize(value: kotlin.String?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterString.allocationSize(value) + } + } + + override fun write(value: kotlin.String?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterString.write(value, buf) + } + } +} + From 8a08d07cc1d75650a286a99f3516b76b5838ba6e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 20:57:29 -0600 Subject: [PATCH 75/97] Fix `cove_ur` generation for android --- .../java/org/bitcoinppl/cove_core/cove.kt | 10 +- .../org/bitcoinppl/cove_core/ur/cove_ur.kt | 2546 +++++++++++++++++ rust/crates/cove-ur/uniffi.toml | 11 + 3 files changed, 2562 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove_core/ur/cove_ur.kt create mode 100644 rust/crates/cove-ur/uniffi.toml 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 04f8e922b..cbb97f978 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 @@ -106,8 +106,8 @@ import org.bitcoinppl.cove_core.types.Utxo import org.bitcoinppl.cove_core.types.UtxoList import org.bitcoinppl.cove_core.types.UtxoType import org.bitcoinppl.cove_core.types.WalletId -import uniffi.cove_ur.FfiConverterTypeUrError -import uniffi.cove_ur.UrException +import org.bitcoinppl.cove_core.ur.FfiConverterTypeUrError +import org.bitcoinppl.cove_core.ur.UrException import org.bitcoinppl.cove_core.device.RustBuffer as RustBufferKeychainError import org.bitcoinppl.cove_core.nfc.RustBuffer as RustBufferNfcMessage import org.bitcoinppl.cove_core.tapcard.RustBuffer as RustBufferTapCardParseError @@ -142,7 +142,7 @@ import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxo import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxoList import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferUtxoType import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferWalletId -import uniffi.cove_ur.RustBuffer as RustBufferUrError +import org.bitcoinppl.cove_core.ur.RustBuffer as RustBufferUrError // This is a helper for safely working with byte buffers returned from the Rust code. // A rust-owned buffer is represented by its capacity, its current length, and a @@ -1849,7 +1849,7 @@ internal object UniffiLib { org.bitcoinppl.cove_core.nfc.uniffiEnsureInitialized() org.bitcoinppl.cove_core.tapcard.uniffiEnsureInitialized() org.bitcoinppl.cove_core.types.uniffiEnsureInitialized() - uniffi.cove_ur.uniffiEnsureInitialized() + org.bitcoinppl.cove_core.ur.uniffiEnsureInitialized() } external fun uniffi_cove_fn_clone_ffiapp(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -43991,7 +43991,7 @@ object AddressExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler { override fun lift(error_buf: RustBuffer.ByValue): UrException = - uniffi.cove_ur.UrException.ErrorHandler.lift( + org.bitcoinppl.cove_core.ur.UrException.ErrorHandler.lift( RustBufferUrError.ByValue().apply { capacity = error_buf.capacity len = error_buf.len diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/ur/cove_ur.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/ur/cove_ur.kt new file mode 100644 index 000000000..aae47c164 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/ur/cove_ur.kt @@ -0,0 +1,2546 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +@file:Suppress("NAME_SHADOWING") + +package org.bitcoinppl.cove_core.ur + +// Common helper code. +// +// Ideally this would live in a separate .kt file where it can be unittested etc +// in isolation, and perhaps even published as a re-useable package. +// +// However, it's important that the details of how this helper code works (e.g. the +// way that different builtin types are passed across the FFI) exactly match what's +// expected by the Rust code on the other side of the interface. In practice right +// now that means coming from the exact some version of `uniffi` that was used to +// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin +// helpers directly inline like we're doing here. + +import com.sun.jna.Library +import com.sun.jna.IntegerType +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Callback +import com.sun.jna.ptr.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +// This is a helper for safely working with byte buffers returned from the Rust code. +// A rust-owned buffer is represented by its capacity, its current length, and a +// pointer to the underlying data. + +/** + * @suppress + */ +@Structure.FieldOrder("capacity", "len", "data") +open class RustBuffer : Structure() { + // Note: `capacity` and `len` are actually `ULong` values, but JVM only supports signed values. + // When dealing with these fields, make sure to call `toULong()`. + @JvmField var capacity: Long = 0 + @JvmField var len: Long = 0 + @JvmField var data: Pointer? = null + + class ByValue: RustBuffer(), Structure.ByValue + class ByReference: RustBuffer(), Structure.ByReference + + internal fun setValue(other: RustBuffer) { + capacity = other.capacity + len = other.len + data = other.data + } + + companion object { + internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + // Note: need to convert the size to a `Long` value to make this work with JVM. + UniffiLib.ffi_cove_ur_rustbuffer_alloc(size.toLong(), status) + }.also { + if(it.data == null) { + throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") + } + } + + internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { + var buf = RustBuffer.ByValue() + buf.capacity = capacity.toLong() + buf.len = len.toLong() + buf.data = data + return buf + } + + internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + UniffiLib.ffi_cove_ur_rustbuffer_free(buf, status) + } + } + + @Suppress("TooGenericExceptionThrown") + fun asByteBuffer() = + this.data?.getByteBuffer(0, this.len.toLong())?.also { + it.order(ByteOrder.BIG_ENDIAN) + } +} + +// This is a helper for safely passing byte references into the rust code. +// It's not actually used at the moment, because there aren't many things that you +// can take a direct pointer to in the JVM, and if we're going to copy something +// then we might as well copy it into a `RustBuffer`. But it's here for API +// completeness. + +@Structure.FieldOrder("len", "data") +internal open class ForeignBytes : Structure() { + @JvmField var len: Int = 0 + @JvmField var data: Pointer? = null + + class ByValue : ForeignBytes(), Structure.ByValue +} +/** + * The FfiConverter interface handles converter types to and from the FFI + * + * All implementing objects should be public to support external types. When a + * type is external we need to import it's FfiConverter. + * + * @suppress + */ +public interface FfiConverter { + // Convert an FFI type to a Kotlin type + fun lift(value: FfiType): KotlinType + + // Convert an Kotlin type to an FFI type + fun lower(value: KotlinType): FfiType + + // Read a Kotlin type from a `ByteBuffer` + fun read(buf: ByteBuffer): KotlinType + + // Calculate bytes to allocate when creating a `RustBuffer` + // + // This must return at least as many bytes as the write() function will + // write. It can return more bytes than needed, for example when writing + // Strings we can't know the exact bytes needed until we the UTF-8 + // encoding, so we pessimistically allocate the largest size possible (3 + // bytes per codepoint). Allocating extra bytes is not really a big deal + // because the `RustBuffer` is short-lived. + fun allocationSize(value: KotlinType): ULong + + // Write a Kotlin type to a `ByteBuffer` + fun write(value: KotlinType, buf: ByteBuffer) + + // Lower a value into a `RustBuffer` + // + // This method lowers a value into a `RustBuffer` rather than the normal + // FfiType. It's used by the callback interface code. Callback interface + // returns are always serialized into a `RustBuffer` regardless of their + // normal FFI type. + fun lowerIntoRustBuffer(value: KotlinType): RustBuffer.ByValue { + val rbuf = RustBuffer.alloc(allocationSize(value)) + try { + val bbuf = rbuf.data!!.getByteBuffer(0, rbuf.capacity).also { + it.order(ByteOrder.BIG_ENDIAN) + } + write(value, bbuf) + rbuf.writeField("len", bbuf.position().toLong()) + return rbuf + } catch (e: Throwable) { + RustBuffer.free(rbuf) + throw e + } + } + + // Lift a value from a `RustBuffer`. + // + // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. + // It's currently only used by the `FfiConverterRustBuffer` class below. + fun liftFromRustBuffer(rbuf: RustBuffer.ByValue): KotlinType { + val byteBuf = rbuf.asByteBuffer()!! + try { + val item = read(byteBuf) + if (byteBuf.hasRemaining()) { + throw RuntimeException("junk remaining in buffer after lifting, something is very wrong!!") + } + return item + } finally { + RustBuffer.free(rbuf) + } + } +} + +/** + * FfiConverter that uses `RustBuffer` as the FfiType + * + * @suppress + */ +public interface FfiConverterRustBuffer: FfiConverter { + override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) + override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) +} +// A handful of classes and functions to support the generated data structures. +// This would be a good candidate for isolating in its own ffi-support lib. + +internal const val UNIFFI_CALL_SUCCESS = 0.toByte() +internal const val UNIFFI_CALL_ERROR = 1.toByte() +internal const val UNIFFI_CALL_UNEXPECTED_ERROR = 2.toByte() + +@Structure.FieldOrder("code", "error_buf") +internal open class UniffiRustCallStatus : Structure() { + @JvmField var code: Byte = 0 + @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() + + class ByValue: UniffiRustCallStatus(), Structure.ByValue + + fun isSuccess(): Boolean { + return code == UNIFFI_CALL_SUCCESS + } + + fun isError(): Boolean { + return code == UNIFFI_CALL_ERROR + } + + fun isPanic(): Boolean { + return code == UNIFFI_CALL_UNEXPECTED_ERROR + } + + companion object { + fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { + val callStatus = UniffiRustCallStatus.ByValue() + callStatus.code = code + callStatus.error_buf = errorBuf + return callStatus + } + } +} + +class InternalException(message: String) : kotlin.Exception(message) + +/** + * Each top-level error class has a companion object that can lift the error from the call status's rust buffer + * + * @suppress + */ +interface UniffiRustCallStatusErrorHandler { + fun lift(error_buf: RustBuffer.ByValue): E; +} + +// Helpers for calling Rust +// In practice we usually need to be synchronized to call this safely, so it doesn't +// synchronize itself + +// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { + var status = UniffiRustCallStatus() + val return_value = callback(status) + uniffiCheckCallStatus(errorHandler, status) + return return_value +} + +// Check UniffiRustCallStatus and throw an error if the call wasn't successful +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { + if (status.isSuccess()) { + return + } else if (status.isError()) { + throw errorHandler.lift(status.error_buf) + } else if (status.isPanic()) { + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if (status.error_buf.len > 0) { + throw InternalException(FfiConverterString.lift(status.error_buf)) + } else { + throw InternalException("Rust panic") + } + } else { + throw InternalException("Unknown rust call status: $status.code") + } +} + +/** + * UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR + * + * @suppress + */ +object UniffiNullRustCallStatusErrorHandler: UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): InternalException { + RustBuffer.free(error_buf) + return InternalException("Unexpected CALL_ERROR") + } +} + +// Call a rust function that returns a plain value +private inline fun uniffiRustCall(callback: (UniffiRustCallStatus) -> U): U { + return uniffiRustCallWithError(UniffiNullRustCallStatusErrorHandler, callback) +} + +internal inline fun uniffiTraitInterfaceCall( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(err) + } +} + +internal inline fun uniffiTraitInterfaceCallWithError( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, + lowerError: (E) -> RustBuffer.ByValue +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + if (e is E) { + callStatus.code = UNIFFI_CALL_ERROR + callStatus.error_buf = lowerError(e) + } else { + val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(err) + } + } +} +// Initial value and increment amount for handles. +// These ensure that Kotlin-generated handles always have the lowest bit set +private const val UNIFFI_HANDLEMAP_INITIAL = 1.toLong() +private const val UNIFFI_HANDLEMAP_DELTA = 2.toLong() + +// Map handles to objects +// +// This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. +internal class UniffiHandleMap { + private val map = ConcurrentHashMap() + // Start + private val counter = java.util.concurrent.atomic.AtomicLong(UNIFFI_HANDLEMAP_INITIAL) + + val size: Int + get() = map.size + + // Insert a new object into the handle map and get a handle for it + fun insert(obj: T): Long { + val handle = counter.getAndAdd(UNIFFI_HANDLEMAP_DELTA) + map.put(handle, obj) + return handle + } + + // Clone a handle, creating a new one + fun clone(handle: Long): Long { + val obj = map.get(handle) ?: throw InternalException("UniffiHandleMap.clone: Invalid handle") + return insert(obj) + } + + // Get an object from the handle map + fun get(handle: Long): T { + return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") + } + + // Remove an entry from the handlemap and get the Kotlin object back + fun remove(handle: Long): T { + return map.remove(handle) ?: throw InternalException("UniffiHandleMap: Invalid handle") + } +} + +// Contains loading, initialization code, +// and the FFI Function declarations in a com.sun.jna.Library. +@Synchronized +private fun findLibraryName(componentName: String): String { + val libOverride = System.getProperty("uniffi.component.$componentName.libraryOverride") + if (libOverride != null) { + return libOverride + } + return "coveffi" +} + +// Define FFI callback types +internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { + fun callback(`data`: Long,`pollResult`: Byte,) +} +internal interface UniffiForeignFutureDroppedCallback : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceClone : com.sun.jna.Callback { + fun callback(`handle`: Long,) + : Long +} +@Structure.FieldOrder("handle", "free") +internal open class UniffiForeignFutureDroppedCallbackStruct( + @JvmField internal var `handle`: Long = 0.toLong(), + @JvmField internal var `free`: UniffiForeignFutureDroppedCallback? = null, +) : Structure() { + class UniffiByValue( + `handle`: Long = 0.toLong(), + `free`: UniffiForeignFutureDroppedCallback? = null, + ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { + `handle` = other.`handle` + `free` = other.`free` + } + +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultU64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultI64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultF32( + @JvmField internal var `returnValue`: Float = 0.0f, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Float = 0.0f, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultF64( + @JvmField internal var `returnValue`: Double = 0.0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Double = 0.0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureResultRustBuffer( + @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultRustBuffer.UniffiByValue,) +} +@Structure.FieldOrder("callStatus") +internal open class UniffiForeignFutureResultVoid( + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureResultVoid(`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultVoid.UniffiByValue,) +} + +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. + +// For large crates we prevent `MethodTooLargeException` (see #2340) +// N.B. the name of the extension is very misleading, since it is +// rather `InterfaceTooLargeException`, caused by too many methods +// in the interface for large crates. +// +// By splitting the otherwise huge interface into two parts +// * UniffiLib (this) +// * IntegrityCheckingUniffiLib +// And all checksum methods are put into `IntegrityCheckingUniffiLib` +// we allow for ~2x as many methods in the UniffiLib interface. +// +// Note: above all written when we used JNA's `loadIndirect` etc. +// We now use JNA's "direct mapping" - unclear if same considerations apply exactly. +internal object IntegrityCheckingUniffiLib { + init { + Native.register(IntegrityCheckingUniffiLib::class.java, findLibraryName(componentName = "cove_ur")) + uniffiCheckContractApiVersion(this) + uniffiCheckApiChecksums(this) + } + external fun uniffi_cove_ur_checksum_method_cryptohdkey_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_to_psbt_bytes( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptopsbt_to_ur( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_encode( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_entropy( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_creation_date( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_name( + ): Short + external fun uniffi_cove_ur_checksum_method_cryptoseed_get_note( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptohdkey_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_from_psbt_bytes( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptopsbt_from_ur( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_decode( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy( + ): Short + external fun uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy_with_metadata( + ): Short + external fun ffi_cove_ur_uniffi_contract_version( + ): Int + + +} + +internal object UniffiLib { + + // The Cleaner for the whole library + internal val CLEANER: UniffiCleaner by lazy { + UniffiCleaner.create() + } + + + init { + Native.register(UniffiLib::class.java, findLibraryName(componentName = "cove_ur")) + + } + external fun uniffi_cove_ur_fn_clone_cryptohdkey(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptohdkey(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptohdkey_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptohdkey_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_clone_cryptopsbt(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptopsbt(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_from_psbt_bytes(`psbtBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptopsbt_from_ur(`ur`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptopsbt_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptopsbt_to_psbt_bytes(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptopsbt_to_ur(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_clone_cryptoseed(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_free_cryptoseed(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_ur_fn_constructor_cryptoseed_decode(`cbor`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy(`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy_with_metadata(`payload`: RustBuffer.ByValue,`name`: RustBuffer.ByValue,`note`: RustBuffer.ByValue,`creationDate`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_ur_fn_method_cryptoseed_encode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_entropy(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_creation_date(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_name(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_cryptoseed_get_note(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_ur_fn_method_urerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun ffi_cove_ur_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_cove_ur_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i8(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_cove_ur_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_cove_ur_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i16(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_cove_ur_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_cove_ur_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_cove_ur_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_u64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_u64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_cove_ur_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_i64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_i64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_cove_ur_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_f32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_f32(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Float + external fun ffi_cove_ur_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_f64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_f64(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Double + external fun ffi_cove_ur_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_cove_ur_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_cancel_void(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_free_void(`handle`: Long, + ): Unit + external fun ffi_cove_ur_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + + +} + +private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { + // Get the bindings contract version from our ComponentInterface + val bindings_contract_version = 30 + // Get the scaffolding contract version by calling the into the dylib + val scaffolding_contract_version = lib.ffi_cove_ur_uniffi_contract_version() + if (bindings_contract_version != scaffolding_contract_version) { + throw RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project") + } +} +@Suppress("UNUSED_PARAMETER") +private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if (lib.uniffi_cove_ur_checksum_method_cryptohdkey_encode() != 5721.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_encode() != 11773.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_to_psbt_bytes() != 52904.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptopsbt_to_ur() != 8360.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_encode() != 48905.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_entropy() != 60295.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_creation_date() != 4217.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_name() != 47847.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_method_cryptoseed_get_note() != 48679.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptohdkey_decode() != 58745.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_decode() != 57233.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_from_psbt_bytes() != 63629.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptopsbt_from_ur() != 823.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_decode() != 59203.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy() != 56252.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_ur_checksum_constructor_cryptoseed_from_entropy_with_metadata() != 24161.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +/** + * @suppress + */ +public fun uniffiEnsureInitialized() { + IntegrityCheckingUniffiLib + // UniffiLib() initialized as objects are used, but we still need to explicitly + // reference it so initialization across crates works as expected. + UniffiLib +} + +// Async support + +// Public interface members begin here. + + +// Interface implemented by anything that can contain an object reference. +// +// Such types expose a `destroy()` method that must be called to cleanly +// dispose of the contained objects. Failure to call this method may result +// in memory leaks. +// +// The easiest way to ensure this method is called is to use the `.use` +// helper method to execute a block and destroy the object at the end. +interface Disposable { + fun destroy() + companion object { + fun destroy(vararg args: Any?) { + for (arg in args) { + when (arg) { + is Disposable -> arg.destroy() + is ArrayList<*> -> { + for (idx in arg.indices) { + val element = arg[idx] + if (element is Disposable) { + element.destroy() + } + } + } + is Map<*, *> -> { + for (element in arg.values) { + if (element is Disposable) { + element.destroy() + } + } + } + is Iterable<*> -> { + for (element in arg) { + if (element is Disposable) { + element.destroy() + } + } + } + } + } + } + } +} + +/** + * @suppress + */ +inline fun T.use(block: (T) -> R) = + try { + block(this) + } finally { + try { + // N.B. our implementation is on the nullable type `Disposable?`. + this?.destroy() + } catch (e: Throwable) { + // swallow + } + } + +/** + * Placeholder object used to signal that we're constructing an interface with a FFI handle. + * + * This is the first argument for interface constructors that input a raw handle. It exists is that + * so we can avoid signature conflicts when an interface has a regular constructor than inputs a + * Long. + * + * @suppress + * */ +object UniffiWithHandle + +/** + * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. + * + * @suppress + * */ +object NoHandle +/** + * The cleaner interface for Object finalization code to run. + * This is the entry point to any implementation that we're using. + * + * The cleaner registers objects and returns cleanables, so now we are + * defining a `UniffiCleaner` with a `UniffiClenaer.Cleanable` to abstract the + * different implmentations available at compile time. + * + * @suppress + */ +interface UniffiCleaner { + interface Cleanable { + fun clean() + } + + fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable + + companion object +} + +// The fallback Jna cleaner, which is available for both Android, and the JVM. +private class UniffiJnaCleaner : UniffiCleaner { + private val cleaner = com.sun.jna.internal.Cleaner.getCleaner() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + UniffiJnaCleanable(cleaner.register(value, cleanUpTask)) +} + +private class UniffiJnaCleanable( + private val cleanable: com.sun.jna.internal.Cleaner.Cleanable, +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + + +// We decide at uniffi binding generation time whether we were +// using Android or not. +// There are further runtime checks to chose the correct implementation +// of the cleaner. +private fun UniffiCleaner.Companion.create(): UniffiCleaner = + try { + // For safety's sake: if the library hasn't been run in android_cleaner = true + // mode, but is being run on Android, then we still need to think about + // Android API versions. + // So we check if java.lang.ref.Cleaner is there, and use that… + java.lang.Class.forName("java.lang.ref.Cleaner") + JavaLangRefCleaner() + } catch (e: ClassNotFoundException) { + // … otherwise, fallback to the JNA cleaner. + UniffiJnaCleaner() + } + +private class JavaLangRefCleaner : UniffiCleaner { + val cleaner = java.lang.ref.Cleaner.create() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + JavaLangRefCleanable(cleaner.register(value, cleanUpTask)) +} + +private class JavaLangRefCleanable( + val cleanable: java.lang.ref.Cleaner.Cleanable +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + +/** + * @suppress + */ +public object FfiConverterULong: FfiConverter { + override fun lift(value: Long): ULong { + return value.toULong() + } + + override fun read(buf: ByteBuffer): ULong { + return lift(buf.getLong()) + } + + override fun lower(value: ULong): Long { + return value.toLong() + } + + override fun allocationSize(value: ULong) = 8UL + + override fun write(value: ULong, buf: ByteBuffer) { + buf.putLong(value.toLong()) + } +} + +/** + * @suppress + */ +public object FfiConverterString: FfiConverter { + // Note: we don't inherit from FfiConverterRustBuffer, because we use a + // special encoding when lowering/lifting. We can use `RustBuffer.len` to + // store our length and avoid writing it out to the buffer. + override fun lift(value: RustBuffer.ByValue): String { + try { + val byteArr = ByteArray(value.len.toInt()) + value.asByteBuffer()!!.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } finally { + RustBuffer.free(value) + } + } + + override fun read(buf: ByteBuffer): String { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } + + fun toUtf8(value: String): ByteBuffer { + // Make sure we don't have invalid UTF-16, check for lone surrogates. + return Charsets.UTF_8.newEncoder().run { + onMalformedInput(CodingErrorAction.REPORT) + encode(CharBuffer.wrap(value)) + } + } + + override fun lower(value: String): RustBuffer.ByValue { + val byteBuf = toUtf8(value) + // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us + // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. + val rbuf = RustBuffer.alloc(byteBuf.limit().toULong()) + rbuf.asByteBuffer()!!.put(byteBuf) + return rbuf + } + + // We aren't sure exactly how many bytes our string will be once it's UTF-8 + // encoded. Allocate 3 bytes per UTF-16 code unit which will always be + // enough. + override fun allocationSize(value: String): ULong { + val sizeForLength = 4UL + val sizeForString = value.length.toULong() * 3UL + return sizeForLength + sizeForString + } + + override fun write(value: String, buf: ByteBuffer) { + val byteBuf = toUtf8(value) + buf.putInt(byteBuf.limit()) + buf.put(byteBuf) + } +} + +/** + * @suppress + */ +public object FfiConverterByteArray: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ByteArray { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr + } + override fun allocationSize(value: ByteArray): ULong { + return 4UL + value.size.toULong() + } + override fun write(value: ByteArray, buf: ByteBuffer) { + buf.putInt(value.size) + buf.put(value) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-hdkey: Hierarchical Deterministic Key (BIP32) + */ +public interface CryptoHdkeyInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + companion object +} + +/** + * crypto-hdkey: Hierarchical Deterministic Key (BIP32) + */ +open class CryptoHdkey: Disposable, AutoCloseable, CryptoHdkeyInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptohdkey(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptohdkey(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptohdkey_encode( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoHdkey { + return FfiConverterTypeCryptoHdkey.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptohdkey_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoHdkey: FfiConverter { + override fun lower(value: CryptoHdkey): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoHdkey { + return CryptoHdkey(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoHdkey { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoHdkey) = 8UL + + override fun write(value: CryptoHdkey, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-psbt: PSBT encoded as CBOR byte string with tag 310 + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +public interface CryptoPsbtInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + /** + * Get PSBT as bytes + */ + fun `toPsbtBytes`(): kotlin.ByteArray + + /** + * Encode as UR string + */ + fun `toUr`(): kotlin.String + + companion object +} + +/** + * crypto-psbt: PSBT encoded as CBOR byte string with tag 310 + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +open class CryptoPsbt: Disposable, AutoCloseable, CryptoPsbtInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptopsbt(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptopsbt(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_encode( + it, + _status) +} + } + ) + } + + + + /** + * Get PSBT as bytes + */override fun `toPsbtBytes`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_to_psbt_bytes( + it, + _status) +} + } + ) + } + + + + /** + * Encode as UR string + */ + @Throws(UrException::class)override fun `toUr`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptopsbt_to_ur( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + /** + * Create from PSBT bytes + */ + @Throws(UrException::class) fun `fromPsbtBytes`(`psbtBytes`: kotlin.ByteArray): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_from_psbt_bytes( + + FfiConverterByteArray.lower(`psbtBytes`),_status) +} + ) + } + + + + /** + * Decode from UR string + */ + @Throws(UrException::class) fun `fromUr`(`ur`: kotlin.String): CryptoPsbt { + return FfiConverterTypeCryptoPsbt.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptopsbt_from_ur( + + FfiConverterString.lower(`ur`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoPsbt: FfiConverter { + override fun lower(value: CryptoPsbt): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoPsbt { + return CryptoPsbt(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoPsbt { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoPsbt) = 8UL + + override fun write(value: CryptoPsbt, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +/** + * crypto-seed: BIP39 seed with optional metadata + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +public interface CryptoSeedInterface { + + /** + * Encode as CBOR for UR + */ + fun `encode`(): kotlin.ByteArray + + /** + * Get entropy bytes + */ + fun `entropy`(): kotlin.ByteArray + + /** + * Get creation date if present + */ + fun `getCreationDate`(): kotlin.ULong? + + /** + * Get name if present + */ + fun `getName`(): kotlin.String? + + /** + * Get note if present + */ + fun `getNote`(): kotlin.String? + + companion object +} + +/** + * crypto-seed: BIP39 seed with optional metadata + * BCR-2020-006: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md + */ +open class CryptoSeed: Disposable, AutoCloseable, CryptoSeedInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_ur_fn_free_cryptoseed(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_ur_fn_clone_cryptoseed(handle, status) + } + } + + + /** + * Encode as CBOR for UR + */ + @Throws(UrException::class)override fun `encode`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_encode( + it, + _status) +} + } + ) + } + + + + /** + * Get entropy bytes + */override fun `entropy`(): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_entropy( + it, + _status) +} + } + ) + } + + + + /** + * Get creation date if present + */override fun `getCreationDate`(): kotlin.ULong? { + return FfiConverterOptionalULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_creation_date( + it, + _status) +} + } + ) + } + + + + /** + * Get name if present + */override fun `getName`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_name( + it, + _status) +} + } + ) + } + + + + /** + * Get note if present + */override fun `getNote`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_cryptoseed_get_note( + it, + _status) +} + } + ) + } + + + + + + + + + companion object { + + /** + * Decode from CBOR + */ + @Throws(UrException::class) fun `decode`(`cbor`: kotlin.ByteArray): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_decode( + + FfiConverterByteArray.lower(`cbor`),_status) +} + ) + } + + + + /** + * Create from entropy bytes + */ + @Throws(UrException::class) fun `fromEntropy`(`payload`: kotlin.ByteArray): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy( + + FfiConverterByteArray.lower(`payload`),_status) +} + ) + } + + + + /** + * Create from entropy bytes with optional metadata + */ + @Throws(UrException::class) fun `fromEntropyWithMetadata`(`payload`: kotlin.ByteArray, `name`: kotlin.String?, `note`: kotlin.String?, `creationDate`: kotlin.ULong?): CryptoSeed { + return FfiConverterTypeCryptoSeed.lift( + uniffiRustCallWithError(UrException) { _status -> + UniffiLib.uniffi_cove_ur_fn_constructor_cryptoseed_from_entropy_with_metadata( + + FfiConverterByteArray.lower(`payload`),FfiConverterOptionalString.lower(`name`),FfiConverterOptionalString.lower(`note`),FfiConverterOptionalULong.lower(`creationDate`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCryptoSeed: FfiConverter { + override fun lower(value: CryptoSeed): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CryptoSeed { + return CryptoSeed(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CryptoSeed { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CryptoSeed) = 8UL + + override fun write(value: CryptoSeed, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + + + + +sealed class UrException: kotlin.Exception() { + + class CborEncodeException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class CborDecodeException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidField( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class MissingField( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidKeyDataLength( + + val `expected`: kotlin.ULong, + + val `actual`: kotlin.ULong + ) : UrException() { + override val message + get() = "expected=${ `expected` }, actual=${ `actual` }" + } + + class InvalidTag( + + val `expected`: kotlin.ULong, + + val `actual`: kotlin.ULong + ) : UrException() { + override val message + get() = "expected=${ `expected` }, actual=${ `actual` }" + } + + class InvalidPayloadLength( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class UrParseException( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidOperation( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidKeyData( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + class MasterKeyNotAllowed( + ) : UrException() { + override val message + get() = "" + } + + class InvalidUtf8( + + val v1: kotlin.String + ) : UrException() { + override val message + get() = "v1=${ v1 }" + } + + + + + // The local Rust `Display`/`Debug` implementation. + override fun toString(): String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_ur_fn_method_urerror_uniffi_trait_display(FfiConverterTypeUrError.lower(this), + _status) +} + ) + } + + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): UrException = FfiConverterTypeUrError.lift(error_buf) + } + + +} + +/** + * @suppress + */ +public object FfiConverterTypeUrError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UrException { + + + return when(buf.getInt()) { + 1 -> UrException.CborEncodeException( + FfiConverterString.read(buf), + ) + 2 -> UrException.CborDecodeException( + FfiConverterString.read(buf), + ) + 3 -> UrException.InvalidField( + FfiConverterString.read(buf), + ) + 4 -> UrException.MissingField( + FfiConverterString.read(buf), + ) + 5 -> UrException.InvalidKeyDataLength( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + 6 -> UrException.InvalidTag( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + 7 -> UrException.InvalidPayloadLength( + FfiConverterString.read(buf), + ) + 8 -> UrException.UrParseException( + FfiConverterString.read(buf), + ) + 9 -> UrException.InvalidOperation( + FfiConverterString.read(buf), + ) + 10 -> UrException.InvalidKeyData( + FfiConverterString.read(buf), + ) + 11 -> UrException.MasterKeyNotAllowed() + 12 -> UrException.InvalidUtf8( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: UrException): ULong { + return when(value) { + is UrException.CborEncodeException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.CborDecodeException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidField -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.MissingField -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidKeyDataLength -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterULong.allocationSize(value.`expected`) + + FfiConverterULong.allocationSize(value.`actual`) + ) + is UrException.InvalidTag -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterULong.allocationSize(value.`expected`) + + FfiConverterULong.allocationSize(value.`actual`) + ) + is UrException.InvalidPayloadLength -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.UrParseException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidOperation -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.InvalidKeyData -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UrException.MasterKeyNotAllowed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is UrException.InvalidUtf8 -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: UrException, buf: ByteBuffer) { + when(value) { + is UrException.CborEncodeException -> { + buf.putInt(1) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.CborDecodeException -> { + buf.putInt(2) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidField -> { + buf.putInt(3) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.MissingField -> { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidKeyDataLength -> { + buf.putInt(5) + FfiConverterULong.write(value.`expected`, buf) + FfiConverterULong.write(value.`actual`, buf) + Unit + } + is UrException.InvalidTag -> { + buf.putInt(6) + FfiConverterULong.write(value.`expected`, buf) + FfiConverterULong.write(value.`actual`, buf) + Unit + } + is UrException.InvalidPayloadLength -> { + buf.putInt(7) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.UrParseException -> { + buf.putInt(8) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidOperation -> { + buf.putInt(9) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.InvalidKeyData -> { + buf.putInt(10) + FfiConverterString.write(value.v1, buf) + Unit + } + is UrException.MasterKeyNotAllowed -> { + buf.putInt(11) + Unit + } + is UrException.InvalidUtf8 -> { + buf.putInt(12) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalULong: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.ULong? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterULong.read(buf) + } + + override fun allocationSize(value: kotlin.ULong?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterULong.allocationSize(value) + } + } + + override fun write(value: kotlin.ULong?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterULong.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalString: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.String? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterString.read(buf) + } + + override fun allocationSize(value: kotlin.String?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterString.allocationSize(value) + } + } + + override fun write(value: kotlin.String?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterString.write(value, buf) + } + } +} + diff --git a/rust/crates/cove-ur/uniffi.toml b/rust/crates/cove-ur/uniffi.toml new file mode 100644 index 000000000..3acf70856 --- /dev/null +++ b/rust/crates/cove-ur/uniffi.toml @@ -0,0 +1,11 @@ +[bindings.swift] +cdylib_name = "coveurffi" + +[bindings.kotlin] +package_name = "org.bitcoinppl.cove_core.ur" +cdylib_name = "coveffi" + +[bindings.kotlin.external_packages] +cove_types = "org.bitcoinppl.cove_core.types" +cove_util = "org.bitcoinppl.cove_core.util" + From 63450342b6b4dbabdbf94e578911e1b66663753e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 20:57:45 -0600 Subject: [PATCH 76/97] Fix android sidebar `Add Wallet` button --- .../main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 9b9509a95..e05812275 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,8 +38,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor @@ -56,8 +53,6 @@ fun SidebarView( app: AppManager, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() - Column( modifier = modifier @@ -150,9 +145,7 @@ fun SidebarView( Modifier .fillMaxWidth() .clickable { - app.isSidebarVisible = false - coroutineScope.launch { - delay(300) + app.closeSidebarAndNavigate { if (app.wallets.isEmpty()) { app.resetRoute(RouteFactory().newWalletSelect()) } else { From 2420eb48c96db7adbb82dcbda60d8902d1a6adfc Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 01:13:37 -0600 Subject: [PATCH 77/97] Pre-load sidebar wallets on network and decoy mode changes Call loadWallets() in SelectedNetworkChanged and WalletModeChanged reconcile handlers so the sidebar wallet list is ready before opening, rather than loading on-demand when the sidebar becomes visible. --- android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt | 2 ++ ios/Cove/AppManager.swift | 2 ++ 2 files changed, 4 insertions(+) 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 a1ac73af1..e74b50911 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -536,6 +536,7 @@ class AppManager private constructor() : FfiReconcile { is AppStateReconcileMessage.SelectedNetworkChanged -> { selectedNetwork = message.v1 + loadWallets() } is AppStateReconcileMessage.DefaultRouteChanged -> { @@ -571,6 +572,7 @@ class AppManager private constructor() : FfiReconcile { is AppStateReconcileMessage.WalletModeChanged -> { isLoading = true + loadWallets() launch { kotlinx.coroutines.delay(200) isLoading = false diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index d2de00d17..d98bba5f5 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -230,6 +230,7 @@ import SwiftUI case let .selectedNetworkChanged(network): self.selectedNetwork = network + self.loadWallets() case let .defaultRouteChanged(route, nestedRoutes): self.router.routes = nestedRoutes @@ -258,6 +259,7 @@ import SwiftUI case .walletModeChanged: self.isLoading = true + self.loadWallets() Task { try? await Task.sleep(for: .milliseconds(200)) From e5a271c7e354eaee95cceb8e67bad658db4c11c9 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 01:44:47 -0600 Subject: [PATCH 78/97] Fix Android TapSigner PIN entry and add NFC scanning overlay PIN entry fixes: - Fix invisible PIN circles by removing inner padding that left no visible area (Compose modifier ordering issue) - Add Context.findActivity() extension to properly unwrap ContextWrapper in ModalBottomSheets, fixing "Unable to access NFC" errors - Wrap enableReaderMode in runOnUiThread to ensure main thread execution NFC scanning overlay: - Add TapSignerScanningOverlay composable with NFC icon, animated "Scanning..." dots, message, and progress indicator - Add isScanning/scanMessage state to TapSignerManager - Show overlay in TapSignerContainer during all NFC operations - Add onMessageUpdate callback to TapCardNfcManager for real-time progress updates from Rust (appendMessage adds dots like iOS) - Update TapSignerEnterPinView, TapSignerConfirmPinView, and TapSignerSetupRetryView to use scanning overlay with progress --- .../java/org/bitcoinppl/cove/ContextExt.kt | 26 +++++ .../org/bitcoinppl/cove/TapSignerContainer.kt | 10 +- .../org/bitcoinppl/cove/TapSignerManager.kt | 4 + .../hot_wallet/HotWalletImportScreen.kt | 3 +- .../cove/nfc/NfcLabelImportSheet.kt | 3 +- .../org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 3 +- .../bitcoinppl/cove/nfc/TapCardNfcManager.kt | 46 +++++---- .../cove/settings/SettingsScreen.kt | 3 +- .../cove/sidebar/SidebarContainer.kt | 3 +- .../cove/tapsigner/PinCirclesView.kt | 5 +- .../cove/tapsigner/TapSignerConfirmPinView.kt | 33 ++++++- .../cove/tapsigner/TapSignerEnterPinView.kt | 52 +++++++++- .../tapsigner/TapSignerScanningOverlay.kt | 99 +++++++++++++++++++ .../cove/tapsigner/TapSignerSetupRetryView.kt | 20 +++- .../org/bitcoinppl/cove/ui/theme/Theme.kt | 7 +- .../org/bitcoinppl/cove/views/LockView.kt | 3 +- 16 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/ContextExt.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ContextExt.kt b/android/app/src/main/java/org/bitcoinppl/cove/ContextExt.kt new file mode 100644 index 000000000..838d87db8 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/ContextExt.kt @@ -0,0 +1,26 @@ +package org.bitcoinppl.cove + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.fragment.app.FragmentActivity + +/** Unwraps a Context to find the containing Activity */ +fun Context.findActivity(): Activity? { + var ctx = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} + +/** Unwraps a Context to find the containing FragmentActivity */ +fun Context.findFragmentActivity(): FragmentActivity? { + var ctx = this + while (ctx is ContextWrapper) { + if (ctx is FragmentActivity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt index b40bcea2a..c0e7c87fd 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt @@ -1,5 +1,6 @@ package org.bitcoinppl.cove +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,7 +24,8 @@ fun TapSignerContainer( // use current route or last in path val currentRoute = manager.path.lastOrNull() ?: manager.initialRoute - when (currentRoute) { + Box(modifier = modifier.fillMaxSize()) { + when (currentRoute) { is TapSignerRoute.InitSelect -> { TapSignerChooseChainCode( app = app, @@ -118,5 +120,11 @@ fun TapSignerContainer( modifier = modifier.fillMaxSize(), ) } + } + + // show scanning overlay when NFC is active + if (manager.isScanning) { + TapSignerScanningOverlay(message = manager.scanMessage) + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerManager.kt index 016c6c7cc..a5d623b7b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerManager.kt @@ -28,6 +28,10 @@ class TapSignerManager( var enteredPin: String? by mutableStateOf(null) + // NFC scanning state + var isScanning by mutableStateOf(false) + var scanMessage by mutableStateOf("Hold your phone near the TapSigner") + fun getOrCreateNfc(tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner): TapSignerNfcHelper { // recreate NFC helper if TapSigner has changed if (nfc != null && nfcFor === tapSigner) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index 5c82b17e2..8a47739ff 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -84,6 +84,7 @@ import org.bitcoinppl.cove.ImportWalletManager import org.bitcoinppl.cove.QrCodeScanView import org.bitcoinppl.cove.R import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.findActivity import org.bitcoinppl.cove.nfc.NfcReadingState import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.title3 @@ -726,7 +727,7 @@ private fun NfcScannerSheet( onWordsScanned: (List>) -> Unit, ) { val context = LocalContext.current - val activity = context as? Activity + val activity = context.findActivity() if (activity == null) { // fallback if not in activity context diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt index 88acf5155..f90ada03b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import org.bitcoinppl.cove.findActivity import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove_core.LabelManager @@ -48,7 +49,7 @@ fun NfcLabelImportSheet( onError: (String) -> Unit, ) { val context = LocalContext.current - val activity = context as? Activity + val activity = context.findActivity() if (activity == null) { // fallback if not in activity context diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt index 2701142f9..7869b4a04 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.delay import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.findActivity import org.bitcoinppl.cove.ui.theme.title3 import org.bitcoinppl.cove_core.multiFormatTryFromNfcMessage import org.bitcoinppl.cove_core.nfc.NfcMessage @@ -50,7 +51,7 @@ fun NfcScanSheet( onDismiss: () -> Unit, ) { val context = LocalContext.current - val activity = context as? Activity + val activity = context.findActivity() var errorMessage by remember { mutableStateOf(null) } var isScanning by remember { mutableStateOf(false) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/TapCardNfcManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/TapCardNfcManager.kt index 43b344103..ff63f149b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/TapCardNfcManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/TapCardNfcManager.kt @@ -34,6 +34,9 @@ class TapCardNfcManager private constructor() { private var tagDetected = CompletableDeferred() private var isScanning = false + // message callback for UI updates (setMessage/appendMessage from transport) + var onMessageUpdate: ((String) -> Unit)? = null + // prevents concurrent NFC operations private val operationMutex = Mutex() @@ -82,20 +85,24 @@ class TapCardNfcManager private constructor() { isScanning = true // enable reader mode for ISO14443 tags (TapSigner uses ISO7816) - nfcAdapter.enableReaderMode( - activity, - { nfcTag -> - Log.d(tag, "NFC tag detected: ${nfcTag.techList.joinToString()}") - if (!tagDetected.isCompleted) { - tagDetected.complete(nfcTag) - } - }, - NfcAdapter.FLAG_READER_NFC_A or - NfcAdapter.FLAG_READER_NFC_B or - NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or - NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, - null, - ) + // must be called on UI thread + activity.runOnUiThread { + nfcAdapter.enableReaderMode( + activity, + { nfcTag -> + Log.d(tag, "NFC tag detected: ${nfcTag.techList.joinToString()}") + if (!tagDetected.isCompleted) { + tagDetected.complete(nfcTag) + } + }, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + null, + ) + Log.d(tag, "NFC reader mode enabled") + } // handle tag detection and command execution val job = @@ -122,8 +129,8 @@ class TapCardNfcManager private constructor() { Log.d(tag, "Connected to IsoDep tag") - // create transport - val transport = TapCardTransport(isoDep) + // create transport with message callback + val transport = TapCardTransport(isoDep, onMessageUpdate) // create TapSignerReader using factory function (workaround for UniFFI async constructor limitation) Log.d(tag, "Creating TapSignerReader with command using factory function") @@ -203,16 +210,21 @@ class TapCardNfcManager private constructor() { */ private class TapCardTransport( private val isoDep: IsoDep, + private val onMessageUpdate: ((String) -> Unit)?, ) : TapcardTransportProtocol { private val tag = "TapCardTransport" + private var currentMessage = "" override fun setMessage(message: String) { - // Android NFC doesn't support updating UI message during transaction Log.d(tag, "Message: $message") + currentMessage = message + onMessageUpdate?.invoke(currentMessage) } override fun appendMessage(message: String) { Log.d(tag, "Append: $message") + currentMessage += message + onMessageUpdate?.invoke(currentMessage) } override suspend fun transmitApdu(commandApdu: ByteArray): ByteArray { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index b14376ca4..0371486c2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -53,6 +53,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.R +import org.bitcoinppl.cove.findFragmentActivity import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection import org.bitcoinppl.cove.views.MaterialSettingsItem @@ -232,7 +233,7 @@ private fun WalletSettingsSection(app: org.bitcoinppl.cove.AppManager) { @Composable private fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { val context = LocalContext.current - val activity = context as? FragmentActivity + val activity = context.findFragmentActivity() val auth = Auth val biometricManager = remember { BiometricManager.from(context) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index c3cd3523b..bcb133f72 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.findActivity import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt @@ -98,7 +99,7 @@ fun SidebarContainer( } if (app.isSidebarVisible) { - (context as? Activity)?.moveTaskToBack(true) + context.findActivity()?.moveTaskToBack(true) } else { app.isSidebarVisible = true } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt index cb7471d38..f624e3faa 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt @@ -38,10 +38,7 @@ private fun PinCircle( modifier: Modifier = Modifier, ) { Surface( - modifier = - modifier - .size(18.dp) - .padding(horizontal = 10.dp), + modifier = modifier.size(18.dp), shape = CircleShape, color = if (isFilled) MaterialTheme.colorScheme.primary else Color.Transparent, border = diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerConfirmPinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerConfirmPinView.kt index 592b2cca1..d4b296434 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerConfirmPinView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerConfirmPinView.kt @@ -41,6 +41,8 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.findActivity +import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove_core.TapSignerConfirmPinArgs import org.bitcoinppl.cove_core.TapSignerPinAction import org.bitcoinppl.cove_core.TapSignerRoute @@ -71,7 +73,7 @@ fun TapSignerConfirmPinView( if (confirmPin.length == 6) { delay(200) scope.launch { - val activity = context as? android.app.Activity + val activity = context.findActivity() if (activity == null) { app.alertState = TaggedItem( @@ -218,8 +220,19 @@ private suspend fun setupTapSigner( } } + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to set up" + manager.isScanning = true + try { val response = nfc.setupTapSigner(args.startingPin, args.newPin, chainCodeBytes) + manager.isScanning = false + nfcManager.onMessageUpdate = null when (response) { is org.bitcoinppl.cove_core.SetupCmdResponse.Complete -> { @@ -230,6 +243,9 @@ private suspend fun setupTapSigner( } } } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + // check if we can continue from last response val lastResponse = nfc.lastResponse() val setupResponse = @@ -257,9 +273,21 @@ private suspend fun changeTapSignerPin( ) { val nfc = manager.getOrCreateNfc(args.tapSigner) + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to change PIN" + manager.isScanning = true + try { nfc.changePin(args.startingPin, args.newPin) + manager.isScanning = false + nfcManager.onMessageUpdate = null + app.sheetState = null app.alertState = TaggedItem( AppAlertState.General( @@ -268,6 +296,9 @@ private suspend fun changeTapSignerPin( ), ) } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + android.util.Log.e("TapSignerConfirmPin", "Error changing PIN", e) // check error type and show appropriate alert diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerEnterPinView.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerEnterPinView.kt index 1cad8d5bc..a886e481d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerEnterPinView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerEnterPinView.kt @@ -35,6 +35,8 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.findActivity +import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove_core.AfterPinAction import org.bitcoinppl.cove_core.TapSignerPinAction import org.bitcoinppl.cove_core.types.Psbt @@ -143,7 +145,7 @@ fun TapSignerEnterPinView( if (newPin.length == 6) { manager.enteredPin = newPin scope.launch { - val activity = context as? android.app.Activity + val activity = context.findActivity() if (activity == null) { app.alertState = TaggedItem( @@ -192,10 +194,10 @@ private suspend fun runAction( changeAction(manager, tapSigner, pin) } is AfterPinAction.Backup -> { - backupAction(app, nfc, tapSigner, pin, createBackupLauncher, activity) + backupAction(app, manager, nfc, tapSigner, pin, createBackupLauncher, activity) } is AfterPinAction.Sign -> { - signAction(app, nfc, action.v1, pin, activity) + signAction(app, manager, nfc, action.v1, pin, activity) } } } @@ -208,8 +210,19 @@ private suspend fun deriveAction( pin: String, activity: android.app.Activity, ) { + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to import wallet" + manager.isScanning = true try { val deriveInfo = nfc.derive(pin) + manager.isScanning = false + nfcManager.onMessageUpdate = null + manager.resetRoute( org.bitcoinppl.cove_core.TapSignerRoute.ImportSuccess( tapSigner, @@ -217,6 +230,9 @@ private suspend fun deriveAction( ), ) } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + // handle auth errors silently, show alert for other errors if (!isAuthError(e)) { app.alertState = @@ -248,14 +264,26 @@ private fun changeAction( private suspend fun backupAction( app: AppManager, + manager: TapSignerManager, nfc: TapSignerNfcHelper, tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner, pin: String, createBackupLauncher: androidx.activity.result.ActivityResultLauncher, activity: android.app.Activity, ) { + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to backup" + manager.isScanning = true try { val backup = nfc.backup(pin) + manager.isScanning = false + nfcManager.onMessageUpdate = null + // save backup and show export dialog app.saveTapSignerBackup(tapSigner, backup) @@ -263,6 +291,9 @@ private suspend fun backupAction( val fileName = "${tapSigner.identFileNamePrefix()}_backup.txt" createBackupLauncher.launch(fileName) } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + if (!isAuthError(e)) { app.alertState = org.bitcoinppl.cove.TaggedItem( @@ -277,13 +308,25 @@ private suspend fun backupAction( private suspend fun signAction( app: AppManager, + manager: TapSignerManager, nfc: TapSignerNfcHelper, psbt: Psbt, pin: String, activity: android.app.Activity, ) { + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to sign" + manager.isScanning = true try { val signedPsbt = nfc.sign(psbt, pin) + manager.isScanning = false + nfcManager.onMessageUpdate = null + val db = org.bitcoinppl.cove_core .Database() @@ -300,6 +343,9 @@ private suspend fun signAction( app.sheetState = null app.pushRoute(route) } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + if (!isAuthError(e)) { app.alertState = org.bitcoinppl.cove.TaggedItem( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt new file mode 100644 index 000000000..ccf0ef2a8 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt @@ -0,0 +1,99 @@ +package org.bitcoinppl.cove.tapsigner + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Nfc +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** + * Overlay shown during TapSigner NFC operations + * displays scanning status and instructions to user + */ +@Composable +fun TapSignerScanningOverlay( + message: String, + modifier: Modifier = Modifier, +) { + var dotCount by remember { mutableIntStateOf(1) } + + LaunchedEffect(Unit) { + while (true) { + delay(400) + dotCount = (dotCount % 3) + 1 + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.padding(32.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Nfc, + contentDescription = "NFC", + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Scanning" + ".".repeat(dotCount), + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + ) + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerSetupRetryView.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerSetupRetryView.kt index ab6ca2490..f2b2a2de5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerSetupRetryView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerSetupRetryView.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove.findActivity +import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove_core.SetupCmdResponse import org.bitcoinppl.cove_core.TapSignerRoute @@ -109,7 +111,7 @@ fun TapSignerSetupRetryView( Button( onClick = { scope.launch { - val activity = context as? android.app.Activity + val activity = context.findActivity() if (activity == null) { app.alertState = TaggedItem( @@ -122,8 +124,21 @@ fun TapSignerSetupRetryView( } val nfc = manager.getOrCreateNfc(tapSigner) + + // set up message callback for progress updates + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> + manager.scanMessage = message + } + + manager.scanMessage = "Hold your phone near the TapSigner to continue setup" + manager.isScanning = true + try { val result = nfc.continueSetup(response) + manager.isScanning = false + nfcManager.onMessageUpdate = null + when (result) { is SetupCmdResponse.Complete -> { manager.resetRoute(TapSignerRoute.SetupSuccess(tapSigner, result.v1)) @@ -133,6 +148,9 @@ fun TapSignerSetupRetryView( } } } catch (e: Exception) { + manager.isScanning = false + nfcManager.onMessageUpdate = null + app.sheetState = null app.alertState = TaggedItem( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt index d28fff5b2..34ff219f0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import org.bitcoinppl.cove.findActivity /** * Extension to check if the current ColorScheme is light mode. @@ -73,8 +74,10 @@ fun CoveTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + view.context.findActivity()?.let { activity -> + val window = activity.window + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index 78c69d65a..01edd21c1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -49,6 +49,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.UnlockMode +import org.bitcoinppl.cove.findFragmentActivity import org.bitcoinppl.cove_core.AuthType private enum class Screen { @@ -63,7 +64,7 @@ fun LockView( val auth = Auth var screen by remember { mutableStateOf(Screen.BIOMETRIC) } val context = LocalContext.current - val activity = context as? FragmentActivity + val activity = context.findFragmentActivity() val biometricManager = remember { BiometricManager.from(context) } val isBiometricAvailable = From 8a5b98fb2f153e178d037448dbfd9467b20cf89b Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 09:37:08 -0600 Subject: [PATCH 79/97] Rename parity doc and add NFC platform differences Rename COMPOSE_IOS_PARITY.md to IOS_ANDROID_PARITY.md to better reflect bidirectional platform parity guidance. Add NFC Scanning UI section documenting the key difference: - iOS gets system NFC popup via NFCTagReaderSession - Android needs custom overlay since enableReaderMode is silent - Both use TapcardTransportProtocol for progress messages --- ARCHITECTURE.md | 2 +- ...SE_IOS_PARITY.md => IOS_ANDROID_PARITY.md} | 44 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) rename docs/{COMPOSE_IOS_PARITY.md => IOS_ANDROID_PARITY.md} (74%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 99393b436..9ce523246 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,7 +149,7 @@ This pattern is used throughout the codebase for shared resources and is safe to } ``` -**Compose ↔ iOS parity patterns:** For detailed guidance on matching iOS behavior in Compose (opacity, text colors, button centering, AutoSizeText, etc.), see [docs/COMPOSE_IOS_PARITY.md](docs/COMPOSE_IOS_PARITY.md). +**iOS ↔ Android parity patterns:** For detailed guidance on matching behavior across platforms (opacity, text colors, button centering, NFC scanning UI, etc.), see [docs/IOS_ANDROID_PARITY.md](docs/IOS_ANDROID_PARITY.md). ### Manager Pattern (cross-platform) diff --git a/docs/COMPOSE_IOS_PARITY.md b/docs/IOS_ANDROID_PARITY.md similarity index 74% rename from docs/COMPOSE_IOS_PARITY.md rename to docs/IOS_ANDROID_PARITY.md index 413802b09..300e13e4d 100644 --- a/docs/COMPOSE_IOS_PARITY.md +++ b/docs/IOS_ANDROID_PARITY.md @@ -1,4 +1,4 @@ -# Compose ↔ iOS Parity Guide +# iOS ↔ Android Parity Guide This document covers platform-specific gotchas and patterns for achieving visual and behavioral parity between Android (Jetpack Compose) and iOS (SwiftUI). @@ -94,3 +94,45 @@ Use the custom implementations in `views/AutoSizeText.kt` for: } ``` - **Note**: `ImageButton` handles text sizing internally using native `TextAutoSize`. + +--- + +## NFC Scanning UI + +- **iOS**: `NFCTagReaderSession` provides automatic system NFC popup. Messages display via `session.alertMessage` property. +- **Android**: `enableReaderMode` is silent—no system UI. Custom overlay required. + +### Transport Protocol Messages + +Both platforms implement `TapcardTransportProtocol` with `setMessage()` and `appendMessage()` (called by Rust during NFC operations to show progress): + +**iOS** (`TapCardTransport` in `ios/Cove/TapSignerNFC.swift`): +```swift +func setMessage(message: String) { + nfcSession.alertMessage = message +} + +func appendMessage(message: String) { + nfcSession.alertMessage = nfcSession.alertMessage + message +} +``` + +**Android** (`TapCardTransport` in `android/.../nfc/TapCardNfcManager.kt`): +```kotlin +override fun setMessage(message: String) { + currentMessage = message + onMessageUpdate?.invoke(currentMessage) +} + +override fun appendMessage(message: String) { + currentMessage += message + onMessageUpdate?.invoke(currentMessage) +} +``` + +### Custom Overlay (Android only) + +Since Android has no system NFC UI, `TapSignerScanningOverlay` composable provides visual feedback: +- NFC icon, animated "Scanning..." dots, message text, progress indicator +- Message updates via callback → `manager.scanMessage` state → recomposition +- Shown in `TapSignerContainer` when `manager.isScanning` is true From 65fdef0b166e0e6e0c65bbf39f6f7acd6f56cbff Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 10:02:18 -0600 Subject: [PATCH 80/97] Fix NfcReader to call enableReaderMode on UI thread Wrap enableReaderMode and disableReaderMode in runOnUiThread to ensure they execute on the main thread, matching TapCardNfcManager and Android NFC API requirements. --- .../java/org/bitcoinppl/cove/nfc/NfcReader.kt | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt index 74c5bbc19..27c0dd97e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcReader.kt @@ -56,25 +56,31 @@ class NfcReader( message = "Hold your phone near the NFC tag" readingState = NfcReadingState.WAITING - nfcAdapter.enableReaderMode( - activity, - { tag -> - handleTag(tag) - }, - NfcAdapter.FLAG_READER_NFC_A or - NfcAdapter.FLAG_READER_NFC_B or - NfcAdapter.FLAG_READER_NFC_F or - NfcAdapter.FLAG_READER_NFC_V or - NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, - null, - ) + // must be called on UI thread + activity.runOnUiThread { + nfcAdapter.enableReaderMode( + activity, + { tag -> + handleTag(tag) + }, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_NFC_F or + NfcAdapter.FLAG_READER_NFC_V or + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + null, + ) + } } fun stopScanning() { isScanning = false message = "" readingState = NfcReadingState.WAITING - nfcAdapter?.disableReaderMode(activity) + // must be called on UI thread + activity.runOnUiThread { + nfcAdapter?.disableReaderMode(activity) + } } private fun handleTag(tag: Tag) { From b8061e9ad7b77f92d505a043a164ee6d75fd3b1c Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 10:09:58 -0600 Subject: [PATCH 81/97] Use app-level QR/NFC scanners for hot wallet import --- .../org/bitcoinppl/cove/TapSignerContainer.kt | 170 +++++++++--------- .../hot_wallet/HotWalletImportScreen.kt | 1 - .../hot_wallet/HotWalletSelectScreen.kt | 7 +- .../cove/nfc/NfcLabelImportSheet.kt | 1 - .../org/bitcoinppl/cove/nfc/NfcScanSheet.kt | 1 - .../cove/settings/SettingsScreen.kt | 1 - .../cove/sidebar/SidebarContainer.kt | 1 - .../cove/tapsigner/PinCirclesView.kt | 1 - .../tapsigner/TapSignerScanningOverlay.kt | 7 +- .../org/bitcoinppl/cove/ui/theme/Theme.kt | 1 - .../org/bitcoinppl/cove/views/LockView.kt | 1 - 11 files changed, 93 insertions(+), 99 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt index c0e7c87fd..367bcb224 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/TapSignerContainer.kt @@ -26,100 +26,100 @@ fun TapSignerContainer( Box(modifier = modifier.fillMaxSize()) { when (currentRoute) { - is TapSignerRoute.InitSelect -> { - TapSignerChooseChainCode( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.InitSelect -> { + TapSignerChooseChainCode( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.InitAdvanced -> { - TapSignerAdvancedChainCode( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.InitAdvanced -> { + TapSignerAdvancedChainCode( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.StartingPin -> { - TapSignerStartingPinView( - app = app, - manager = manager, - tapSigner = currentRoute.tapSigner, - chainCode = currentRoute.chainCode, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.StartingPin -> { + TapSignerStartingPinView( + app = app, + manager = manager, + tapSigner = currentRoute.tapSigner, + chainCode = currentRoute.chainCode, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.NewPin -> { - TapSignerNewPinView( - app = app, - manager = manager, - args = currentRoute.v1, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.NewPin -> { + TapSignerNewPinView( + app = app, + manager = manager, + args = currentRoute.v1, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.ConfirmPin -> { - TapSignerConfirmPinView( - app = app, - manager = manager, - args = currentRoute.v1, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.ConfirmPin -> { + TapSignerConfirmPinView( + app = app, + manager = manager, + args = currentRoute.v1, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.SetupSuccess -> { - TapSignerSetupSuccessView( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - setup = currentRoute.v2, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.SetupSuccess -> { + TapSignerSetupSuccessView( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + setup = currentRoute.v2, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.SetupRetry -> { - TapSignerSetupRetryView( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - response = currentRoute.v2, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.SetupRetry -> { + TapSignerSetupRetryView( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + response = currentRoute.v2, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.EnterPin -> { - TapSignerEnterPinView( - app = app, - manager = manager, - tapSigner = currentRoute.tapSigner, - action = currentRoute.action, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.EnterPin -> { + TapSignerEnterPinView( + app = app, + manager = manager, + tapSigner = currentRoute.tapSigner, + action = currentRoute.action, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.ImportSuccess -> { - TapSignerImportSuccessView( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - deriveInfo = currentRoute.v2, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.ImportSuccess -> { + TapSignerImportSuccessView( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + deriveInfo = currentRoute.v2, + modifier = modifier.fillMaxSize(), + ) + } - is TapSignerRoute.ImportRetry -> { - TapSignerImportRetryView( - app = app, - manager = manager, - tapSigner = currentRoute.v1, - modifier = modifier.fillMaxSize(), - ) - } + is TapSignerRoute.ImportRetry -> { + TapSignerImportRetryView( + app = app, + manager = manager, + tapSigner = currentRoute.v1, + modifier = modifier.fillMaxSize(), + ) + } } // show scanning overlay when NFC is active diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index 8a47739ff..651884deb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.flow.new_wallet.hot_wallet -import android.app.Activity import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt index 77f1a1189..09dfacd7e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt @@ -232,12 +232,13 @@ fun HotWalletSelectScreen( modifier = Modifier.padding(bottom = 8.dp), ) - // show QR and NFC options for import + // show QR and NFC options for import - use app-level scan sheets + // which handle all formats (mnemonic, TapSigner, hardware export, etc.) if (nextScreen == NextScreenDialog.Import) { TextButton( onClick = { - navigateToRoute(NumberOfBip39Words.TWENTY_FOUR, ImportType.QR) showSheet = false + app.scanQr() }, modifier = Modifier.fillMaxWidth(), ) { @@ -246,8 +247,8 @@ fun HotWalletSelectScreen( TextButton( onClick = { - navigateToRoute(NumberOfBip39Words.TWENTY_FOUR, ImportType.NFC) showSheet = false + app.scanNfc() }, modifier = Modifier.fillMaxWidth(), ) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt index f90ada03b..976c3a6da 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcLabelImportSheet.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.nfc -import android.app.Activity import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt index 7869b4a04..c9e3aaff5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/nfc/NfcScanSheet.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.nfc -import android.app.Activity import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt index 0371486c2..ee4be9dd8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/settings/SettingsScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.R import org.bitcoinppl.cove.findFragmentActivity diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt index bcb133f72..5b7c0e1e1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarContainer.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.sidebar -import android.app.Activity import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt index f624e3faa..dd67bed7a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/PinCirclesView.kt @@ -2,7 +2,6 @@ package org.bitcoinppl.cove.tapsigner import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt index ccf0ef2a8..b9b5be443 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tapsigner/TapSignerScanningOverlay.kt @@ -49,9 +49,10 @@ fun TapSignerScanningOverlay( } Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.7f)), + modifier = + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)), contentAlignment = Alignment.Center, ) { Surface( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt index 34ff219f0..f021b88c7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package org.bitcoinppl.cove.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index 01edd21c1..4495e8909 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner From d34ef385ea42294133ec57a485a5c60e03fea535 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 10:17:53 -0600 Subject: [PATCH 82/97] Add error handling for invalid QR/NFC in iOS import screen --- .../HotWallet/HotWalletImportScreen.swift | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift index 2bff6e4e7..1311a5700 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift @@ -189,12 +189,18 @@ struct HotWalletImportScreen: View { if let words = try? groupedPlainWordsOf(mnemonic: mnemonicString, groups: UInt8(groupsOf)) { setWords(words) } + } else { + sheetState = .none + app.alertState = TaggedItem(.general( + title: "Invalid QR Code", + message: "Please scan a valid seed phrase QR code" + )) } scanner.reset() case let .inProgress(_, haptic): - haptic.trigger() // multi-part QR in progress - keep scanning + haptic.trigger() } } catch { Log.error("Seed QR failed to scan: \(error.localizedDescription)") @@ -547,6 +553,8 @@ struct HotWalletImportScreen: View { } func onChangeNfcMessage(_: NfcMessage?, _ new: NfcMessage?) { + guard new != nil else { return } + // try string first if let string = new?.string() { do { @@ -558,14 +566,21 @@ struct HotWalletImportScreen: View { } // if string doesn't work, try data - guard let data = new?.data() else { return } - do { - let seedQR = try SeedQr.newFromData(data: data) - let words = seedQR.groupedPlainWords(groupsOf: UInt8(groupsOf)) - setWords(words) - } catch { - Log.error("Error NFC word parsing from data: \(error)") + if let data = new?.data() { + do { + let seedQR = try SeedQr.newFromData(data: data) + let words = seedQR.groupedPlainWords(groupsOf: UInt8(groupsOf)) + return setWords(words) + } catch { + Log.error("Error NFC word parsing from data: \(error)") + } } + + // both failed - show error + app.alertState = TaggedItem(.general( + title: "Invalid NFC Tag", + message: "Please scan a valid seed phrase NFC tag" + )) } func onChangeEnteredWords(_: [[String]], _ new: [[String]]) { From 010ab2624cac01ba848243b379fd7f353a7f20b1 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 11:58:40 -0600 Subject: [PATCH 83/97] Add keyboard toolbar with Max button to Send Flow --- .../org/bitcoinppl/cove/SendFlowContainer.kt | 3 + .../org/bitcoinppl/cove/send/SendScreen.kt | 222 ++++++++++++------ 2 files changed, 156 insertions(+), 69 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt index 23a248e53..613bc6154 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SendFlowContainer.kt @@ -217,6 +217,9 @@ private fun SendFlowRouteToScreen( onClearAmount = { sendFlowManager.dispatch(SendFlowManagerAction.ClearSendAmount) }, + onMaxSelected = { + sendFlowManager.dispatch(SendFlowManagerAction.SelectMaxSend) + }, onToggleBalanceVisibility = { walletManager.dispatch(WalletManagerAction.ToggleSensitiveVisibility) }, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt index 2ad869bce..04614bc40 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/send/SendScreen.kt @@ -4,6 +4,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -11,6 +16,7 @@ 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.ArrowDropDown +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.QrCode2 @@ -25,11 +31,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -45,6 +55,8 @@ import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.AutoSizeTextField import org.bitcoinppl.cove.views.ImageButton +private enum class SendFocusField { None, Amount, Address } + @Preview() @Composable private fun SendScreenPreview() { @@ -81,6 +93,7 @@ fun SendScreen( onScanQr: () -> Unit, onChangeSpeed: () -> Unit, onClearAmount: () -> Unit = {}, + onMaxSelected: () -> Unit = {}, onToggleBalanceVisibility: () -> Unit = {}, onUnitChange: (String) -> Unit = {}, onToggleFiatOrBtc: () -> Unit = {}, @@ -104,6 +117,10 @@ fun SendScreen( onAddressChanged: (String) -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { + var focusedField by remember { mutableStateOf(SendFocusField.None) } + val focusManager = LocalFocusManager.current + val addressFocusRequester = remember { FocusRequester() } + Scaffold( containerColor = CoveColor.midnightBlue, topBar = { @@ -153,58 +170,96 @@ fun SendScreen( onToggleVisibility = onToggleBalanceVisibility, height = headerHeight, ) - Column( + + val density = LocalDensity.current + val isKeyboardVisible = WindowInsets.ime.getBottom(density) > 0 + + Box( modifier = Modifier .fillMaxWidth() .weight(1f) .clip(RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)) - .background(MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), + .background(MaterialTheme.colorScheme.surface), ) { - AmountWidget( - initialAmount = amountText, - denomination = amountDenomination, - dollarText = dollarEquivalentText, - secondaryUnit = secondaryUnit, - onAmountChanged = onAmountChanged, - onClearAmount = onClearAmount, - onUnitChange = onUnitChange, - onToggleFiatOrBtc = onToggleFiatOrBtc, - onSanitizeBtcAmount = onSanitizeBtcAmount, - onSanitizeFiatAmount = onSanitizeFiatAmount, - isFiatMode = isFiatMode, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) - AddressWidget( - onScanQr = onScanQr, - initialAddress = initialAddress, - onAddressChanged = onAddressChanged, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) - SpendingWidget( - accountShort = accountShort, - feeEta = feeEta, - feeAmount = feeAmount, - totalSpendingCrypto = totalSpendingCrypto, - totalSpendingFiat = totalSpendingFiat, - onChangeSpeed = onChangeSpeed, - ) - Spacer(Modifier.weight(1f)) - ImageButton( - text = stringResource(R.string.btn_next), - onClick = onNext, - colors = - ButtonDefaults.buttonColors( - containerColor = midnightBtn(), - contentColor = Color.White, - ), + Column( modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - ) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + AmountWidget( + initialAmount = amountText, + denomination = amountDenomination, + dollarText = dollarEquivalentText, + secondaryUnit = secondaryUnit, + onAmountChanged = onAmountChanged, + onClearAmount = onClearAmount, + onUnitChange = onUnitChange, + onToggleFiatOrBtc = onToggleFiatOrBtc, + onSanitizeBtcAmount = onSanitizeBtcAmount, + onSanitizeFiatAmount = onSanitizeFiatAmount, + isFiatMode = isFiatMode, + onFocusChanged = { focused -> + focusedField = if (focused) SendFocusField.Amount else SendFocusField.None + }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) + AddressWidget( + onScanQr = onScanQr, + initialAddress = initialAddress, + onAddressChanged = onAddressChanged, + focusRequester = addressFocusRequester, + onFocusChanged = { focused -> + focusedField = if (focused) SendFocusField.Address else SendFocusField.None + }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) + SpendingWidget( + accountShort = accountShort, + feeEta = feeEta, + feeAmount = feeAmount, + totalSpendingCrypto = totalSpendingCrypto, + totalSpendingFiat = totalSpendingFiat, + onChangeSpeed = onChangeSpeed, + ) + Spacer(Modifier.weight(1f)) + ImageButton( + text = stringResource(R.string.btn_next), + onClick = onNext, + colors = + ButtonDefaults.buttonColors( + containerColor = midnightBtn(), + contentColor = Color.White, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) + } + + // Keyboard toolbar - only show for amount field + if (isKeyboardVisible && focusedField == SendFocusField.Amount) { + KeyboardToolbar( + onMaxSelected = onMaxSelected, + onNextOrDone = { + if (initialAddress.isEmpty()) { + addressFocusRequester.requestFocus() + } else { + focusManager.clearFocus() + } + }, + onClear = onClearAmount, + hasAddress = initialAddress.isNotEmpty(), + modifier = + Modifier + .align(Alignment.BottomCenter) + .consumeWindowInsets(WindowInsets.navigationBars) + .imePadding(), + ) + } } } } @@ -283,6 +338,7 @@ private fun AmountWidget( onSanitizeBtcAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, onSanitizeFiatAmount: (oldValue: String, newValue: String) -> String? = { _, _ -> null }, isFiatMode: Boolean = false, + onFocusChanged: (Boolean) -> Unit = {}, ) { var amount by remember { mutableStateOf(initialAmount) } var showUnitMenu by remember { mutableStateOf(false) } @@ -350,32 +406,11 @@ private fun AmountWidget( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().offset(x = amountOffset), onTextWidthChanged = { width -> textWidth = width }, - onFocusChanged = { focused -> isFocused = focused }, + onFocusChanged = { focused -> + isFocused = focused + onFocusChanged(focused) + }, ) - // clear button: positioned at top, horizontally follows the text's right edge - // text is centered with an offset, so X position = offset + half text width + small margin - // only show when focused and has content - if (isFocused && amount.isNotEmpty()) { - IconButton( - onClick = { - amount = "" - onAmountChanged("") - onClearAmount() - }, - modifier = - Modifier - .align(Alignment.TopCenter) - .offset(x = amountOffset + (textWidth / 2) + 16.dp) - .size(32.dp), - ) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear amount", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } } Spacer(Modifier.width(32.dp)) Box { @@ -468,6 +503,8 @@ private fun AddressWidget( onScanQr: () -> Unit, initialAddress: String, onAddressChanged: (String) -> Unit, + focusRequester: FocusRequester, + onFocusChanged: (Boolean) -> Unit = {}, ) { var address by remember { mutableStateOf(initialAddress) } var isFocused by remember { mutableStateOf(false) } @@ -537,8 +574,10 @@ private fun AddressWidget( modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .onFocusChanged { focusState -> isFocused = focusState.isFocused + onFocusChanged(focusState.isFocused) }, ) Spacer(Modifier.height(24.dp)) @@ -636,3 +675,48 @@ private fun SpendingWidget( Spacer(Modifier.height(20.dp)) } } + +@Composable +private fun KeyboardToolbar( + onMaxSelected: () -> Unit, + onNextOrDone: () -> Unit, + onClear: () -> Unit, + hasAddress: Boolean, + modifier: Modifier = Modifier, +) { + val buttonText = if (!hasAddress) "Next" else "Done" + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Left: Next or Done + FilledTonalButton(onClick = onNextOrDone) { + Text(buttonText) + } + + // Right: Max + Clear + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onMaxSelected) { + Text("Max") + } + + FilledTonalButton(onClick = onClear) { + Icon( + Icons.Filled.Cancel, + contentDescription = "Clear", + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} From 795bf874f1907cd7027856af512dcca95ff38710 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 12:11:28 -0600 Subject: [PATCH 84/97] Polish Android hot wallet and transaction UI - Refactor word input grid to use Column layout instead of LazyVerticalGrid - Improve last word handling with checksum suggestions and keyboard dismiss - Switch verify screen animations from tween to spring for smoother feel - Use BasicText with TextAutoSize for consistent text scaling - Normalize spacing to 16.dp across screens --- .../hot_wallet/HotWalletImportScreen.kt | 156 ++++++++++++------ .../hot_wallet/HotWalletSelectScreen.kt | 2 +- .../hot_wallet/HotWalletVerifyScreen.kt | 73 +++++--- .../TransactionDetailsScreen.kt | 2 +- .../org/bitcoinppl/cove/views/ImageButton.kt | 8 +- .../ReceiveAddressSheet.kt | 2 +- 6 files changed, 162 insertions(+), 81 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt index 651884deb..70d291e54 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletImportScreen.kt @@ -16,9 +16,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -59,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale @@ -193,7 +191,7 @@ fun HotWalletImportScreen( Bip39WordSpecificAutocomplete( wordNumber = (idx + 1).toUShort(), numberOfWords = numberOfWords, - ).isBip39Word(word) + ).isValidWord(word, enteredWords) } fun importWallet() { @@ -351,7 +349,13 @@ fun HotWalletImportScreen( ImageButton( text = stringResource(R.string.action_import_wallet), - onClick = { if (isAllWordsValid()) importWallet() }, + onClick = { + if (isAllWordsValid()) { + importWallet() + } else { + alertState = AlertState.InvalidWords + } + }, colors = ButtonDefaults.buttonColors( containerColor = CoveColor.btnPrimary, @@ -458,6 +462,10 @@ private fun WordInputGrid( val flatWords = enteredWords.flatten() + val numRows = wordCount / 2 + val leftIndices = (0 until numRows) + val rightIndices = (numRows until wordCount) + Card( modifier = Modifier.fillMaxWidth(), colors = @@ -466,43 +474,77 @@ private fun WordInputGrid( ), shape = RoundedCornerShape(10.dp), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - itemsIndexed( - items = flatWords, - key = { index, _ -> "word-input-$index" }, - ) { index, word -> - WordInputField( - number = index + 1, - word = word, - numberOfWords = numberOfWords, - allEnteredWords = enteredWords, - isFocused = focusedField == index, - onWordChanged = { newWord -> - val groupIndex = index / GROUPS_OF - val wordIndex = index % GROUPS_OF - val newWords = enteredWords.toMutableList() - val newGroup = newWords[groupIndex].toMutableList() - newGroup[wordIndex] = newWord - newWords[groupIndex] = newGroup - onWordsChanged(newWords) - }, - onFocusChanged = { hasFocus -> - if (hasFocus) onFocusChanged(index) - }, - onNext = { - if (index < wordCount - 1) { - onFocusChanged(index + 1) - } - }, - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + leftIndices.forEach { index -> + WordInputField( + number = index + 1, + word = flatWords[index], + numberOfWords = numberOfWords, + allEnteredWords = enteredWords, + isLastWord = index == wordCount - 1, + isFocused = focusedField == index, + onWordChanged = { newWord -> + val groupIndex = index / GROUPS_OF + val wordIndex = index % GROUPS_OF + val newWords = enteredWords.toMutableList() + val newGroup = newWords[groupIndex].toMutableList() + newGroup[wordIndex] = newWord + newWords[groupIndex] = newGroup + onWordsChanged(newWords) + }, + onFocusChanged = { hasFocus -> + if (hasFocus) onFocusChanged(index) + }, + onNext = { + if (index < wordCount - 1) { + onFocusChanged(index + 1) + } + }, + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + rightIndices.forEach { index -> + WordInputField( + number = index + 1, + word = flatWords[index], + numberOfWords = numberOfWords, + allEnteredWords = enteredWords, + isLastWord = index == wordCount - 1, + isFocused = focusedField == index, + onWordChanged = { newWord -> + val groupIndex = index / GROUPS_OF + val wordIndex = index % GROUPS_OF + val newWords = enteredWords.toMutableList() + val newGroup = newWords[groupIndex].toMutableList() + newGroup[wordIndex] = newWord + newWords[groupIndex] = newGroup + onWordsChanged(newWords) + }, + onFocusChanged = { hasFocus -> + if (hasFocus) onFocusChanged(index) + }, + onNext = { + if (index < wordCount - 1) { + onFocusChanged(index + 1) + } + }, + ) + } } } } @@ -514,6 +556,7 @@ private fun WordInputField( word: String, numberOfWords: NumberOfBip39Words, allEnteredWords: List>, + isLastWord: Boolean, isFocused: Boolean, onWordChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, @@ -528,8 +571,9 @@ private fun WordInputField( } var suggestions by remember { mutableStateOf>(emptyList()) } + val focusManager = LocalFocusManager.current - val isValid = word.isNotEmpty() && autocomplete.isBip39Word(word) + val isValid = word.isNotEmpty() && autocomplete.isValidWord(word, allEnteredWords) val hasInput = word.isNotEmpty() // underline color based on state (matching iOS) @@ -566,13 +610,16 @@ private fun WordInputField( } // update suggestions when word or focus changes - LaunchedEffect(word, isFocused) { - suggestions = - if (isFocused && word.isNotEmpty()) { - autocomplete.autocomplete(word, allEnteredWords) - } else { - emptyList() - } + // for last word, show checksum suggestions even when empty + // don't show suggestions if word is already valid (user selected one) + LaunchedEffect(word, isFocused, allEnteredWords) { + suggestions = when { + !isFocused -> emptyList() + isValid -> emptyList() + isLastWord -> autocomplete.autocomplete(word, allEnteredWords) + word.isNotEmpty() -> autocomplete.autocomplete(word, allEnteredWords) + else -> emptyList() + } } Column { @@ -599,9 +646,14 @@ private fun WordInputField( onWordChanged(trimmed) // auto-advance when word is complete and valid - if (trimmed.isNotEmpty() && autocomplete.isBip39Word(trimmed)) { + if (trimmed.isNotEmpty() && autocomplete.isValidWord(trimmed, allEnteredWords)) { suggestions = emptyList() - onNext() + if (isLastWord) { + // dismiss keyboard, let user manually click Import Wallet + focusManager.clearFocus() + } else { + onNext() + } } }, textStyle = @@ -617,11 +669,12 @@ private fun WordInputField( capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Next, + imeAction = if (isLastWord) ImeAction.Done else ImeAction.Next, ), keyboardActions = KeyboardActions( onNext = { onNext() }, + onDone = { focusManager.clearFocus() }, ), modifier = Modifier @@ -670,7 +723,12 @@ private fun WordInputField( .clickable { onWordChanged(suggestion) suggestions = emptyList() - onNext() + if (isLastWord) { + // dismiss keyboard, let user manually click Import Wallet + focusManager.clearFocus() + } else { + onNext() + } }.padding(horizontal = 12.dp, vertical = 10.dp), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt index 09dfacd7e..70077c994 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletSelectScreen.kt @@ -175,7 +175,7 @@ fun HotWalletSelectScreen( ) Column( - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(), ) { ImageButton( diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt index 729b6408d..7407cc67d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flow/new_wallet/hot_wallet/HotWalletVerifyScreen.kt @@ -1,8 +1,8 @@ package org.bitcoinppl.cove.flow.new_wallet.hot_wallet import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -57,6 +59,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot 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.text.style.TextOverflow @@ -69,13 +72,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor -import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.DashDotsIndicator import org.bitcoinppl.cove_core.WordCheckState import org.bitcoinppl.cove_core.WordVerifyStateMachine import kotlin.math.hypot import kotlin.math.roundToInt +private const val DWELL_MS_CORRECT = 300L +private const val DWELL_MS_INCORRECT = 500L + @OptIn(ExperimentalMaterial3Api::class) @Composable fun HotWalletVerifyScreen( @@ -133,19 +138,23 @@ fun HotWalletVerifyScreen( animationX.snapTo(startPos.x) animationY.snapTo(startPos.y) - val moveMs = config.moveDurationMsCorrect.toInt() + // spring animation matching iOS spring().speed(2.0) + val springSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow * 2f, + ) coroutineScope { launch { animationX.animateTo( targetValue = targetPosition.x, - animationSpec = tween(moveMs, easing = LinearEasing), + animationSpec = springSpec, ) } launch { animationY.animateTo( targetValue = targetPosition.y, - animationSpec = tween(moveMs, easing = LinearEasing), + animationSpec = springSpec, ) } } @@ -156,8 +165,8 @@ fun HotWalletVerifyScreen( } is WordCheckState.Correct -> { - // dwell then advance - delay(config.dwellDurationMsCorrect.toLong()) + // brief dwell to show green, then advance (matching iOS spring timing) + delay(DWELL_MS_CORRECT) val transition = stateMachine.dwellComplete() checkState = transition.newState @@ -172,8 +181,8 @@ fun HotWalletVerifyScreen( } is WordCheckState.Incorrect -> { - // dwell then start return - delay(config.dwellDurationMsIncorrect.toLong()) + // brief dwell to show red before returning (matching iOS spring timing) + delay(DWELL_MS_INCORRECT) val transition = stateMachine.dwellComplete() checkState = transition.newState } @@ -183,19 +192,23 @@ fun HotWalletVerifyScreen( val word = state.word val originPos = wordPositions[word] ?: return@LaunchedEffect - val moveMs = config.moveDurationMsIncorrect.toInt() + // spring animation matching iOS spring().speed(3.0) + val springSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow * 3f, + ) coroutineScope { launch { animationX.animateTo( targetValue = originPos.x, - animationSpec = tween(moveMs, easing = LinearEasing), + animationSpec = springSpec, ) } launch { animationY.animateTo( targetValue = originPos.y, - animationSpec = tween(moveMs, easing = LinearEasing), + animationSpec = springSpec, ) } } @@ -452,7 +465,6 @@ fun HotWalletVerifyScreen( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp) .clickable { showSkipAlert = true }, ) } @@ -511,11 +523,20 @@ fun HotWalletVerifyScreen( .zIndex(10f), contentAlignment = Alignment.Center, ) { - Text( + BasicText( text = currentWord, - color = overlayText, - fontWeight = FontWeight.Medium, maxLines = 1, + autoSize = TextAutoSize.StepBased( + minFontSize = 7.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp, + ), + style = TextStyle( + color = overlayText, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + modifier = Modifier.padding(horizontal = 6.dp), ) } } @@ -554,13 +575,19 @@ private fun OptionChip( }, contentAlignment = Alignment.Center, ) { - AutoSizeText( + BasicText( text = text, - color = textColor, - fontWeight = FontWeight.Medium, - maxFontSize = 14.sp, - minimumScaleFactor = 0.50f, - textAlign = TextAlign.Center, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + minFontSize = 7.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp, + ), + style = TextStyle( + color = textColor, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), modifier = Modifier.padding(horizontal = 6.dp), ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt index fb5072ec6..49f3a3f8d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/transaction_details/TransactionDetailsScreen.kt @@ -547,7 +547,7 @@ fun TransactionDetailsScreen( .fillMaxWidth(), ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(16.dp)) TextButton( onClick = { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt index ba045fd53..69bd50fde 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/ImageButton.kt @@ -67,14 +67,10 @@ fun ImageButton( BasicText( text = text, maxLines = 1, - autoSize = - TextAutoSize.StepBased( - minFontSize = 7.sp, - maxFontSize = 14.sp, - stepSize = 0.5.sp, - ), + autoSize = TextAutoSize.StepBased(minFontSize = 7.sp, maxFontSize = 14.sp, stepSize = 0.5.sp), style = TextStyle( + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = LocalContentColor.current, textAlign = TextAlign.Center, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt index ce3a73c39..438c60d03 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/ReceiveAddressSheet.kt @@ -302,7 +302,7 @@ private fun ReceiveAddressSheetContent( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) // create new address button TextButton( From f9e124080fded6b292e02b19ba0c752a4967d8b3 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 12:32:47 -0600 Subject: [PATCH 85/97] Add reservedWidth to AutoSizeTextField --- .../org/bitcoinppl/cove/views/AutoSizeText.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt index 5e2adbb64..66fd567ca 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/AutoSizeText.kt @@ -29,7 +29,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** @@ -202,6 +204,7 @@ fun BalanceAutoSizeText( * @param color Text color * @param fontWeight Font weight * @param textAlign Text alignment + * @param reservedWidth Width to reserve for overlaid elements (e.g., clear button), reduces effective width for sizing * @param onTextWidthChanged Callback with measured text width in Dp (for positioning related elements) */ @Composable @@ -214,6 +217,7 @@ fun AutoSizeTextField( color: Color = Color.Unspecified, fontWeight: FontWeight? = null, textAlign: TextAlign? = null, + reservedWidth: Dp = 0.dp, onTextWidthChanged: ((androidx.compose.ui.unit.Dp) -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null, ) { @@ -223,28 +227,25 @@ fun AutoSizeTextField( val style = LocalTextStyle.current // use TextFieldValue internally to control cursor position - // track the last external value to detect when it changes - var lastExternalValue by remember { mutableStateOf(value) } var textFieldValue by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } - // sync from external value changes (e.g., formatting), keeping cursor at end - // this runs during composition for immediate effect, not in a side effect - if (value != lastExternalValue) { - lastExternalValue = value - if (textFieldValue.text != value) { - textFieldValue = TextFieldValue(value, TextRange(value.length)) - } + // sync from external value if it differs from internal state + // this handles both formatting changes AND rejected input (e.g., sanitization) + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(value, TextRange(value.length)) } BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } + val reservedWidthPx = with(density) { reservedWidth.toPx() } + val effectiveMaxWidthPx = (maxWidthPx - reservedWidthPx).coerceAtLeast(0f) val (fontSize, textWidthPx) = remember( textFieldValue.text, - maxWidthPx, + effectiveMaxWidthPx, maxFontSize, minFontSize, style, @@ -254,7 +255,7 @@ fun AutoSizeTextField( text = textFieldValue.text, maxFontSize = maxFontSize, minFontSize = minFontSize, - maxWidthPx = maxWidthPx, + maxWidthPx = effectiveMaxWidthPx, style = style, fontWeight = fontWeight, fontStyle = null, From fb75b9bcbb3391e779a7dfb63e370fd6b21102aa Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 15:16:17 -0600 Subject: [PATCH 86/97] Replace tryhard with backon for retry logic - Update Cargo.toml dependency from tryhard to backon - Refactor init_prices() and fetch_and_update_prices_if_needed() in fiat/client.rs - Refactor init_fees() in fee_client.rs - Simplify init_on_start() retry loop in app.rs using ConstantBuilder --- rust/Cargo.lock | 35 +++++++++++++++++++++----------- rust/Cargo.toml | 2 +- rust/src/app.rs | 44 ++++++++++++++--------------------------- rust/src/fee_client.rs | 14 ++++++++----- rust/src/fiat/client.rs | 23 +++++++++++++-------- 5 files changed, 64 insertions(+), 54 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7652c194a..df7f2e59c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -231,6 +231,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -753,6 +764,7 @@ dependencies = [ "arc-swap", "argon2", "async-trait", + "backon", "base64 0.22.1", "bbqr", "bdk_electrum", @@ -815,7 +827,6 @@ dependencies = [ "tracing", "tracing-log", "tracing-subscriber", - "tryhard", "uniffi 0.30.0", "url", "winnow", @@ -1428,6 +1439,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "goblin" version = "0.8.2" @@ -3314,16 +3337,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tryhard" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" -dependencies = [ - "pin-project-lite", - "tokio", -] - [[package]] name = "typenum" version = "1.19.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4df78ea81..70a934614 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -140,7 +140,7 @@ act-zero-ext = "0.2.0" tokio = { workspace = true, features = ["rt"] } async-trait = "0.1.81" futures = "0.3.30" -tryhard = "0.5" +backon = "1.3" # crypto rand = { workspace = true } diff --git a/rust/src/app.rs b/rust/src/app.rs index d08c3309c..5aad04db2 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -4,6 +4,8 @@ pub mod reconcile; use std::{sync::Arc, time::Duration}; +use backon::{ConstantBuilder, Retryable as _}; + use crate::{ auth::AuthType, color_scheme::ColorSchemeSelection, @@ -516,39 +518,23 @@ impl FfiApp { crate::task::init_tokio(); // get / update prices - let _state = self.inner().state.clone(); crate::task::spawn(async move { - // init prices and update the client state - if crate::fiat::client::init_prices().await.is_ok() { - let prices = FIAT_CLIENT.get_or_fetch_prices().await; - if let Ok(prices) = prices { - Updater::send_update(AppMessage::FiatPricesChanged(prices.into())); - } - + let init_result = (|| crate::fiat::client::init_prices()) + .retry( + ConstantBuilder::default() + .with_delay(Duration::from_secs(120)) + .with_max_times(5), + ) + .notify(|err, _| warn!("unable to init prices: {err}, trying again")) + .await; + + if init_result.is_err() { + error!("unable to get prices, giving up"); return; } - // failed to get prices, retry 5 times - let mut retries = 0; - loop { - retries += 1; - if retries > 5 { - error!("unable to get prices, giving up"); - break; - } - - tokio::time::sleep(Duration::from_secs(120)).await; - match crate::fiat::client::init_prices().await { - Ok(_) => break, - Err(error) => { - warn!("unable to init prices: {error}, trying again"); - } - } - - let prices = FIAT_CLIENT.get_or_fetch_prices().await; - if let Ok(prices) = prices { - Updater::send_update(AppMessage::FiatPricesChanged(prices.into())); - } + if let Ok(prices) = FIAT_CLIENT.get_or_fetch_prices().await { + Updater::send_update(AppMessage::FiatPricesChanged(prices.into())); } }); diff --git a/rust/src/fee_client.rs b/rust/src/fee_client.rs index 51f939507..9e1ffbde4 100644 --- a/rust/src/fee_client.rs +++ b/rust/src/fee_client.rs @@ -1,9 +1,10 @@ use std::{ sync::{Arc, LazyLock}, - time::Instant, + time::{Duration, Instant}, }; use arc_swap::ArcSwap; +use backon::{ExponentialBuilder, Retryable as _}; use eyre::Result; use tracing::warn; @@ -149,10 +150,13 @@ pub async fn init_fees() { return; } - let result = tryhard::retry_fn(move || FEE_CLIENT.fetch_and_get_fees()) - .retries(20) - .exponential_backoff(std::time::Duration::from_millis(10)) - .max_delay(std::time::Duration::from_secs(5)) + let result = (|| FEE_CLIENT.fetch_and_get_fees()) + .retry( + ExponentialBuilder::default() + .with_min_delay(Duration::from_millis(10)) + .with_max_delay(Duration::from_secs(5)) + .with_max_times(20), + ) .await; if let Err(error) = result { diff --git a/rust/src/fiat/client.rs b/rust/src/fiat/client.rs index 8a5100b50..50f887e2d 100644 --- a/rust/src/fiat/client.rs +++ b/rust/src/fiat/client.rs @@ -4,6 +4,7 @@ use std::{ }; use arc_swap::ArcSwap; +use backon::{ExponentialBuilder, Retryable as _}; use eyre::{Context as _, Result}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -182,10 +183,13 @@ pub async fn init_prices() -> Result<()> { debug!("init_prices"); let fiat_client = &FIAT_CLIENT; - let prices = tryhard::retry_fn(|| fiat_client.get_or_fetch_prices()) - .retries(20) - .exponential_backoff(Duration::from_millis(10)) - .max_delay(Duration::from_secs(5)) + let prices = (|| fiat_client.get_or_fetch_prices()) + .retry( + ExponentialBuilder::default() + .with_min_delay(Duration::from_millis(10)) + .with_max_delay(Duration::from_secs(5)) + .with_max_times(20), + ) .await; match prices { @@ -232,10 +236,13 @@ pub async fn fetch_and_update_prices_if_needed() -> Result<()> { debug!("fetching prices"); let fiat_client = &FIAT_CLIENT; - let prices = tryhard::retry_fn(|| fiat_client.get_or_fetch_prices()) - .retries(5) - .exponential_backoff(Duration::from_millis(10)) - .max_delay(Duration::from_secs(1)) + let prices = (|| fiat_client.get_or_fetch_prices()) + .retry( + ExponentialBuilder::default() + .with_min_delay(Duration::from_millis(10)) + .with_max_delay(Duration::from_secs(1)) + .with_max_times(5), + ) .await?; // saved prices are the same as the new ones don't need to update From 1810dd0ba795b67bd51b2487437a4e80e0051722 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 15:25:02 -0600 Subject: [PATCH 87/97] Make fiat price lookup non-blocking - Change updateFiatBalance() from async to sync using cached prices - Show CircularProgressIndicator when fiat is loading in BalanceWidget - BTC balance displays immediately, fiat shows spinner until cached --- .../java/org/bitcoinppl/cove/WalletManager.kt | 21 +--- .../WalletTransactionsScreen.kt | 84 ++++++++++----- .../java/org/bitcoinppl/cove_core/cove.kt | 102 +++++++++--------- .../TransactionsCardView.swift | 11 +- .../WalletBalanceHeaderView.swift | 51 ++++----- ios/Cove/WalletManager.swift | 24 ++--- .../Sources/CoveCore/generated/cove.swift | 78 +++++++------- rust/src/fiat/client.rs | 40 ++++--- rust/src/manager/wallet_manager.rs | 17 +-- 9 files changed, 228 insertions(+), 200 deletions(-) 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 106627b15..8fa3ed022 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -97,7 +97,7 @@ class WalletManager : this.unsignedTransactions = runCatching { rustManager.getUnsignedTransactions() }.getOrElse { emptyList() } // start fiat balance update - mainScope.launch(Dispatchers.IO) { updateFiatBalance() } + updateFiatBalance() rustManager.listenForUpdates(this) } @@ -180,26 +180,16 @@ class WalletManager : transactionDetailsCache[txId] = details } - private suspend fun updateFiatBalance() { - try { - val fiatBal = rust.balanceInFiat() - withContext(Dispatchers.Main) { - fiatBalance = fiatBal - } - } catch (e: Exception) { - logError("error getting fiat balance", e) - withContext(Dispatchers.Main) { - fiatBalance = 0.0 - } - } + private fun updateFiatBalance() { + fiatBalance = rust.amountInFiat(balance.spendable()) } suspend fun updateWalletBalance() { val bal = rust.balance() withContext(Dispatchers.Main) { balance = bal + updateFiatBalance() } - updateFiatBalance() } private fun apply(message: WalletManagerReconcileMessage) { @@ -234,8 +224,7 @@ class WalletManager : is WalletManagerReconcileMessage.WalletBalanceChanged -> { balance = message.v1 - // update fiat balance in background - mainScope.launch(Dispatchers.IO) { updateFiatBalance() } + updateFiatBalance() } is WalletManagerReconcileMessage.UnsignedTransactionsChanged -> { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt index ca533717a..63ecd16cb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet_transactions/WalletTransactionsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -149,10 +150,10 @@ fun WalletTransactionsScreen( val spendable = it.balance.spendable() it.displayAmount(spendable, showUnit = true) } ?: satsAmount - val actualFiatAmount = + val actualFiatAmount: String? = manager?.fiatBalance?.let { manager.rust.displayFiatAmount(it) - } ?: usdAmount + } val transactions = when (val state = manager?.loadState) { is WalletLoadState.LOADED -> state.txns @@ -304,16 +305,12 @@ fun WalletTransactionsScreen( verticalArrangement = Arrangement.spacedBy(24.dp), ) { val fiatOrBtc = manager?.walletMetadata?.fiatOrBtc ?: FiatOrBtc.BTC - val (primaryAmount, secondaryAmount) = - when (fiatOrBtc) { - FiatOrBtc.FIAT -> actualFiatAmount to actualSatsAmount - FiatOrBtc.BTC -> actualSatsAmount to actualFiatAmount - } BalanceWidget( hidden = initialBalanceHidden, - primaryAmount = primaryAmount, - secondaryAmount = secondaryAmount, + fiatAmount = actualFiatAmount, + btcAmount = actualSatsAmount, + fiatOrBtc = fiatOrBtc, onToggle = { manager?.dispatch(WalletManagerAction.ToggleFiatBtcPrimarySecondary) }, ) @@ -756,31 +753,70 @@ private fun UnsignedTransactionWidget( @Composable private fun BalanceWidget( hidden: Boolean, - primaryAmount: String, - secondaryAmount: String, + fiatAmount: String?, + btcAmount: String, + fiatOrBtc: FiatOrBtc, onToggle: () -> Unit, ) { var isHidden by remember { mutableStateOf(hidden) } + // determine primary/secondary based on fiatOrBtc preference + val primaryIsFiat = fiatOrBtc == FiatOrBtc.FIAT + val primaryAmount = if (primaryIsFiat) fiatAmount else btcAmount + val secondaryAmount = if (primaryIsFiat) btcAmount else fiatAmount + Column( modifier = Modifier.clickable { onToggle() }, verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = if (isHidden) "••••••" else secondaryAmount, - color = Color.White.copy(alpha = 0.7f), - fontSize = 13.sp, - ) + // secondary balance (top, smaller) + if (isHidden) { + Text( + text = "••••••", + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp, + ) + } else if (secondaryAmount != null) { + Text( + text = secondaryAmount, + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + color = Color.White.copy(alpha = 0.7f), + strokeWidth = 1.5.dp, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { - BalanceAutoSizeText( - text = if (isHidden) "••••••" else primaryAmount, - modifier = Modifier.padding(end = 12.dp), - color = Color.White, - baseFontSize = 34.sp, - minimumScaleFactor = 0.5f, - fontWeight = FontWeight.Bold, - ) + // primary balance (bottom, larger) + if (isHidden) { + BalanceAutoSizeText( + text = "••••••", + modifier = Modifier.padding(end = 12.dp), + color = Color.White, + baseFontSize = 34.sp, + minimumScaleFactor = 0.5f, + fontWeight = FontWeight.Bold, + ) + } else if (primaryAmount != null) { + BalanceAutoSizeText( + text = primaryAmount, + modifier = Modifier.padding(end = 12.dp), + color = Color.White, + baseFontSize = 34.sp, + minimumScaleFactor = 0.5f, + fontWeight = FontWeight.Bold, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } Spacer(modifier = Modifier.weight(1f)) Icon( imageVector = if (isHidden) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, 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 cbb97f978..d8f3bfa47 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 @@ -1400,8 +1400,6 @@ external fun uniffi_cove_checksum_method_rustwalletmanager_amount_in_fiat( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_balance( ): Short -external fun uniffi_cove_checksum_method_rustwalletmanager_balance_in_fiat( -): Short external fun uniffi_cove_checksum_method_rustwalletmanager_broadcast_transaction( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_convert_and_display_fiat( @@ -2334,12 +2332,10 @@ external fun uniffi_cove_fn_constructor_rustwalletmanager_try_new_from_xpub(`xpu ): Long external fun uniffi_cove_fn_method_rustwalletmanager_address_at(`ptr`: Long,`index`: Int, ): Long -external fun uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat(`ptr`: Long,`amount`: Long, -): Long +external fun uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat(`ptr`: Long,`amount`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustwalletmanager_balance(`ptr`: Long, ): Long -external fun uniffi_cove_fn_method_rustwalletmanager_balance_in_fiat(`ptr`: Long, -): Long external fun uniffi_cove_fn_method_rustwalletmanager_broadcast_transaction(`ptr`: Long,`signedTransaction`: Long, ): Long external fun uniffi_cove_fn_method_rustwalletmanager_convert_and_display_fiat(`ptr`: Long,`amount`: Long,`prices`: Long,`withSuffix`: Byte,uniffi_out_err: UniffiRustCallStatus, @@ -3750,15 +3746,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustwalletmanager_address_at() != 13093.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustwalletmanager_amount_in_fiat() != 35706.toShort()) { + if (lib.uniffi_cove_checksum_method_rustwalletmanager_amount_in_fiat() != 61774.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_rustwalletmanager_balance() != 14970.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustwalletmanager_balance_in_fiat() != 19007.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_rustwalletmanager_broadcast_transaction() != 63043.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -17872,12 +17865,13 @@ public interface RustWalletManagerInterface { */ suspend fun `addressAt`(`index`: kotlin.UInt): AddressInfo - suspend fun `amountInFiat`(`amount`: Amount): kotlin.Double + /** + * Sync method using cached prices, returns None if no cached prices + */ + fun `amountInFiat`(`amount`: Amount): kotlin.Double? suspend fun `balance`(): Balance - suspend fun `balanceInFiat`(): kotlin.Double - suspend fun `broadcastTransaction`(`signedTransaction`: BitcoinTransaction) fun `convertAndDisplayFiat`(`amount`: Amount, `prices`: PriceResponse, `withSuffix`: kotlin.Boolean = true): kotlin.String @@ -18106,25 +18100,20 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf } - @Throws(WalletManagerException::class) - @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") - override suspend fun `amountInFiat`(`amount`: Amount) : kotlin.Double { - return uniffiRustCallAsync( - callWithHandle { uniffiHandle -> - UniffiLib.uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat( - uniffiHandle, - FfiConverterTypeAmount.lower(`amount`), - ) - }, - { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_f64(future, callback, continuation) }, - { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_f64(future, continuation) }, - { future -> UniffiLib.ffi_cove_rust_future_free_f64(future) }, - // lift function - { FfiConverterDouble.lift(it) }, - // Error FFI converter - WalletManagerException.ErrorHandler, + /** + * Sync method using cached prices, returns None if no cached prices + */override fun `amountInFiat`(`amount`: Amount): kotlin.Double? { + return FfiConverterOptionalDouble.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat( + it, + FfiConverterTypeAmount.lower(`amount`),_status) +} + } ) } + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") @@ -18147,27 +18136,6 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf } - @Throws(WalletManagerException::class) - @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") - override suspend fun `balanceInFiat`() : kotlin.Double { - return uniffiRustCallAsync( - callWithHandle { uniffiHandle -> - UniffiLib.uniffi_cove_fn_method_rustwalletmanager_balance_in_fiat( - uniffiHandle, - - ) - }, - { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_f64(future, callback, continuation) }, - { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_f64(future, continuation) }, - { future -> UniffiLib.ffi_cove_rust_future_free_f64(future) }, - // lift function - { FfiConverterDouble.lift(it) }, - // Error FFI converter - WalletManagerException.ErrorHandler, - ) - } - - @Throws(WalletManagerException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `broadcastTransaction`(`signedTransaction`: BitcoinTransaction) { @@ -42525,6 +42493,38 @@ public object FfiConverterOptionalFloat: FfiConverterRustBuffer { +/** + * @suppress + */ +public object FfiConverterOptionalDouble: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.Double? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterDouble.read(buf) + } + + override fun allocationSize(value: kotlin.Double?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterDouble.allocationSize(value) + } + } + + override fun write(value: kotlin.Double?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterDouble.write(value, buf) + } + } +} + + + + /** * @suppress */ diff --git a/ios/Cove/Flows/SelectedWalletFlow/TransactionsCardView.swift b/ios/Cove/Flows/SelectedWalletFlow/TransactionsCardView.swift index e79f1aefc..f5537be05 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/TransactionsCardView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/TransactionsCardView.swift @@ -303,8 +303,10 @@ struct UnsignedTransactionView: View { return privateShow(manager.amountFmtUnit(txn.spendingAmount())) } - // fiat - guard let fiatAmount else { return privateShow("$XX.XX USD") } + // fiat - fall back to BTC if fiat unavailable + guard let fiatAmount else { + return privateShow(manager.amountFmtUnit(txn.spendingAmount())) + } return privateShow(manager.rust.displayFiatAmount(amount: fiatAmount)) } @@ -339,9 +341,8 @@ struct UnsignedTransactionView: View { Text(amount) } } - .task { - fiatAmount = - try? await manager.rust.amountInFiat(amount: txn.spendingAmount()) + .onAppear { + fiatAmount = manager.rust.amountInFiat(amount: txn.spendingAmount()) } .onTapGesture { let hardwareExportRoute = diff --git a/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift b/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift index baa873cdb..422fe4824 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/WalletBalanceHeaderView.swift @@ -20,34 +20,37 @@ struct WalletBalanceHeaderView: View { let updater: (WalletManagerAction) -> Void let showReceiveSheet: () -> Void - private var primaryBalanceString: String { + @ViewBuilder + private var primaryBalanceView: some View { if !metadata.sensitiveVisible { - return "••••••" - } - - // fiat - if metadata.fiatOrBtc == .fiat { - guard let fiatBalance else { return "" } - return manager.rust.displayFiatAmount(amount: fiatBalance) + Text("••••••") + } else if metadata.fiatOrBtc == .fiat { + if let fiatBalance { + Text(manager.rust.displayFiatAmount(amount: fiatBalance)) + } else { + ProgressView() + .tint(.white) + } + } else { + Text(manager.amountFmtUnit(balance)) } - - // btc or sats - return manager.amountFmtUnit(balance) } - private var secondaryBalanceString: String { + @ViewBuilder + private var secondaryBalanceView: some View { if !metadata.sensitiveVisible { - return "••••••" - } - - // fiat - if metadata.fiatOrBtc == .btc { - guard let fiatBalance else { return "" } - return manager.rust.displayFiatAmount(amount: fiatBalance) + Text("••••••") + } else if metadata.fiatOrBtc == .btc { + if let fiatBalance { + Text(manager.rust.displayFiatAmount(amount: fiatBalance)) + } else { + ProgressView() + .tint(.white.opacity(0.75)) + .scaleEffect(0.7) + } + } else { + Text(manager.amountFmtUnit(balance)) } - - // btc or sats - return manager.amountFmtUnit(balance) } var eyeIcon: String { @@ -74,7 +77,7 @@ struct WalletBalanceHeaderView: View { VStack(spacing: 28) { VStack(spacing: 6) { HStack { - Text(secondaryBalanceString) + secondaryBalanceView .foregroundColor(.white.opacity(0.75)) .font(.footnote) .padding(.leading, 2) @@ -84,7 +87,7 @@ struct WalletBalanceHeaderView: View { } HStack { - Text(primaryBalanceString) + primaryBalanceView .foregroundStyle(.white) .font(.system(size: fontSize, weight: .bold)) .contentTransition(.numericText()) diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index a73b46bb5..a95af0c64 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -37,7 +37,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana walletMetadata = rust.walletMetadata() unsignedTransactions = (try? rust.getUnsignedTransactions()) ?? [] - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() rust.listenForUpdates(reconciler: WeakReconciler(self)) } @@ -49,7 +49,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana walletMetadata = metadata id = metadata.id - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() rust.listenForUpdates(reconciler: WeakReconciler(self)) } @@ -133,22 +133,16 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana return details } - private func updateFiatBalance() async { - do { - let fiatBalance = try await rust.balanceInFiat() - await MainActor.run { - withAnimation { self.fiatBalance = fiatBalance } - } - } catch { - Log.error("error getting fiat balance: \(error)") - fiatBalance = 0.00 - } + private func updateFiatBalance() { + fiatBalance = rust.amountInFiat(amount: balance.spendable()) } func updateWalletBalance() async { let balance = await rust.balance() - await MainActor.run { self.balance = balance } - await updateFiatBalance() + await MainActor.run { + self.balance = balance + self.updateFiatBalance() + } } func apply(_ message: Message) { @@ -177,7 +171,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana case let .walletBalanceChanged(balance): withAnimation { self.balance = balance } - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() case .unsignedTransactionsChanged: self.unsignedTransactions = (try? rust.getUnsignedTransactions()) ?? [] diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 70957ed9c..99697ee98 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -7520,12 +7520,13 @@ public protocol RustWalletManagerProtocol: AnyObject, Sendable { */ func addressAt(index: UInt32) async throws -> AddressInfo - func amountInFiat(amount: Amount) async throws -> Double + /** + * Sync method using cached prices, returns None if no cached prices + */ + func amountInFiat(amount: Amount) -> Double? func balance() async -> Balance - func balanceInFiat() async throws -> Double - func broadcastTransaction(signedTransaction: BitcoinTransaction) async throws func convertAndDisplayFiat(amount: Amount, prices: PriceResponse, withSuffix: Bool) -> String @@ -7737,21 +7738,16 @@ open func addressAt(index: UInt32)async throws -> AddressInfo { ) } -open func amountInFiat(amount: Amount)async throws -> Double { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat( - self.uniffiCloneHandle(), - FfiConverterTypeAmount_lower(amount) - ) - }, - pollFunc: ffi_cove_rust_future_poll_f64, - completeFunc: ffi_cove_rust_future_complete_f64, - freeFunc: ffi_cove_rust_future_free_f64, - liftFunc: FfiConverterDouble.lift, - errorHandler: FfiConverterTypeWalletManagerError_lift - ) + /** + * Sync method using cached prices, returns None if no cached prices + */ +open func amountInFiat(amount: Amount) -> Double? { + return try! FfiConverterOptionDouble.lift(try! rustCall() { + uniffi_cove_fn_method_rustwalletmanager_amount_in_fiat( + self.uniffiCloneHandle(), + FfiConverterTypeAmount_lower(amount),$0 + ) +}) } open func balance()async -> Balance { @@ -7772,23 +7768,6 @@ open func balance()async -> Balance { ) } -open func balanceInFiat()async throws -> Double { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_cove_fn_method_rustwalletmanager_balance_in_fiat( - self.uniffiCloneHandle() - - ) - }, - pollFunc: ffi_cove_rust_future_poll_f64, - completeFunc: ffi_cove_rust_future_complete_f64, - freeFunc: ffi_cove_rust_future_free_f64, - liftFunc: FfiConverterDouble.lift, - errorHandler: FfiConverterTypeWalletManagerError_lift - ) -} - open func broadcastTransaction(signedTransaction: BitcoinTransaction)async throws { return try await uniffiRustCallAsync( @@ -26374,6 +26353,30 @@ fileprivate struct FfiConverterOptionFloat: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionDouble: FfiConverterRustBuffer { + typealias SwiftType = Double? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterDouble.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 FfiConverterDouble.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -28666,15 +28669,12 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustwalletmanager_address_at() != 13093) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustwalletmanager_amount_in_fiat() != 35706) { + if (uniffi_cove_checksum_method_rustwalletmanager_amount_in_fiat() != 61774) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_rustwalletmanager_balance() != 14970) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustwalletmanager_balance_in_fiat() != 19007) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_rustwalletmanager_broadcast_transaction() != 63043) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/src/fiat/client.rs b/rust/src/fiat/client.rs index 50f887e2d..64c96ed9a 100644 --- a/rust/src/fiat/client.rs +++ b/rust/src/fiat/client.rs @@ -18,7 +18,11 @@ use super::historical::HistoricalPricesResponse; const CURRENCY_URL: &str = "https://mempool.space/api/v1/prices"; const HISTORICAL_PRICES_URL: &str = "https://mempool.space/api/v1/historical-price"; -const ONE_MIN: u64 = 60; +/// How often to fetch prices from the server 1 minute +const BACKGROUND_REFRESH_INTERVAL: u64 = 60; + +/// Hard limit: never fetch if < 30 seconds since last fetch +const HARD_LIMIT: u64 = 30; // min seconds between fetches // Global client for getting prices pub static FIAT_CLIENT: LazyLock = LazyLock::new(FiatClient::new); @@ -30,7 +34,6 @@ pub static PRICES: LazyLock>> = pub struct FiatClient { url: String, client: reqwest::Client, - wait_before_new_prices: u64, } #[derive( @@ -77,16 +80,20 @@ impl_default_for!(FiatClient); impl FiatClient { fn new() -> Self { - Self { - url: CURRENCY_URL.to_string(), - client: reqwest::Client::new(), - wait_before_new_prices: ONE_MIN, - } + Self { url: CURRENCY_URL.to_string(), client: reqwest::Client::new() } } #[allow(dead_code)] fn new_with_url(url: String) -> Self { - Self { url, client: reqwest::Client::new(), wait_before_new_prices: ONE_MIN } + Self { url, client: reqwest::Client::new() } + } + + /// Sync method using cached prices only, returns None if no cache + pub fn value_in_currency_cached(&self, amount: Amount, currency: FiatCurrency) -> Option { + let prices = self.prices()?; + let btc = amount.as_btc(); + let price = prices.get_for_currency(currency); + Some(btc * price as f64) } /// Get historical price and exchange rates for a specific timestamp @@ -106,24 +113,33 @@ impl FiatClient { /// Get the cached prices, will fetch and update the prices in the background if needed /// Returns None if the prices are not cached pub fn prices(&self) -> Option { + // check in-memory cache first if let Some(prices) = PRICES.load().as_ref() { let now_secs = Timestamp::now().as_second() as u64; - if (now_secs - prices.time) > self.wait_before_new_prices { + if (now_secs - prices.fetched_at) > BACKGROUND_REFRESH_INTERVAL { crate::task::spawn(async move { fetch_and_update_prices_if_needed().await }); } return Some(*prices); } + // fallback to database cache + if let Ok(Some(prices)) = Database::global().global_cache.get_prices() { + debug!("loaded cached prices from database"); + PRICES.swap(Arc::new(Some(prices))); + return Some(prices); + } + + warn!("no cached prices found in memory or database"); None } - /// Always returns the latest prcies, will also update the prices cache + /// Always returns the latest prices, will also update the prices cache pub async fn get_or_fetch_prices(&self) -> Result { trace!("get_or_fetch_prices"); if let Some(prices) = PRICES.load().as_ref() { let now_secs = Timestamp::now().as_second() as u64; - if now_secs - prices.fetched_at < self.wait_before_new_prices { + if now_secs - prices.fetched_at < HARD_LIMIT { return Ok(*prices); } } @@ -229,7 +245,7 @@ pub async fn fetch_and_update_prices_if_needed() -> Result<()> { trace!("fetch_and_update_prices_if_needed"); if let Some(prices) = PRICES.load().as_ref() { let now_secs = Timestamp::now().as_second() as u64; - if now_secs - prices.fetched_at < ONE_MIN { + if now_secs - prices.fetched_at < HARD_LIMIT { return Ok(()); } } diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 5041ed864..5b9752a4d 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -498,22 +498,11 @@ impl RustWalletManager { Ok(()) } + /// Sync method using cached prices, returns None if no cached prices #[uniffi::method] - pub async fn balance_in_fiat(&self) -> Result { - let balance = call!(self.actor.balance()) - .await - .map_err(|_| Error::WalletBalanceError("unable to get balance".to_string()))?; - - self.amount_in_fiat(balance.spendable().into()).await - } - - #[uniffi::method] - pub async fn amount_in_fiat(&self, amount: Arc) -> Result { + pub fn amount_in_fiat(&self, amount: Arc) -> Option { let currency = self.selected_fiat_currency(); - - FIAT_CLIENT.current_value_in_currency(*amount, currency).await.map_err(|error| { - Error::FiatError(format!("unable to get fiat value for amount: {error}")) - }) + FIAT_CLIENT.value_in_currency_cached(*amount, currency) } #[uniffi::method(default(show_unit = true))] From a4a9cb271562b40cf4389b5b7161415812853d43 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 15:31:30 -0600 Subject: [PATCH 88/97] Remove artificial delays when loading wallet - Remove 500ms delay before balance update in loadManager() - Remove 400ms delay before getTransactions/startWalletScan in .task - Call startWalletScan() immediately which sends cached data first - Rust wallet_scan_and_notify() already sends cached balance/transactions before starting the background scan --- .../SelectedWalletFlow/SelectedWalletContainer.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift index 4b28a35a0..3e06a80ea 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift @@ -21,11 +21,6 @@ struct SelectedWalletContainer: View { do { Log.debug("Getting wallet \(id)") manager = try app.getWalletManager(id: id) - - Task { - try await Task.sleep(for: .milliseconds(500)) - await manager?.updateWalletBalance() - } } catch { Log.error("Something went very wrong: \(error)") do { @@ -76,12 +71,9 @@ struct SelectedWalletContainer: View { } .onAppear(perform: loadManager) .task { - // small delay and then start scanning wallet + // start scan immediately (sends cached data first, then scans) if let manager { do { - try await Task.sleep(for: .milliseconds(400)) - await manager.rust.getTransactions() - await manager.updateWalletBalance() try await manager.rust.startWalletScan() } catch { Log.error("Wallet Scan Failed \(error.localizedDescription)") From 650ae04426493bfbc19d34fc5de918c0c4684c96 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 19:47:30 -0600 Subject: [PATCH 89/97] Show cached transactions immediately when switching wallets - Add transactions() method to Wallet for reading cached BDK data - Send cached balance and transactions in try_new() before actor queue - Update availableTransactions handler to accept updates in scanning state - Dispatch reconcile messages directly to MainActor instead of double queue hop through rustBridge then main --- .../java/org/bitcoinppl/cove/WalletManager.kt | 20 ++++++----- ios/Cove/WalletManager.swift | 35 +++++++++---------- rust/src/manager/wallet_manager.rs | 16 ++++++--- rust/src/wallet.rs | 21 +++++++++++ 4 files changed, 62 insertions(+), 30 deletions(-) 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 8fa3ed022..4e91dfbac 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -203,8 +203,12 @@ class WalletManager : } is WalletManagerReconcileMessage.AvailableTransactions -> { - if (loadState is WalletLoadState.LOADING) { - loadState = WalletLoadState.SCANNING(message.v1) + // accept cached transactions in loading/scanning states, or if new count > current + loadState = when (val current = loadState) { + is WalletLoadState.LOADING, is WalletLoadState.SCANNING -> + WalletLoadState.SCANNING(message.v1) + is WalletLoadState.LOADED -> + if (message.v1.size > current.txns.size) WalletLoadState.SCANNING(message.v1) else current } } @@ -271,16 +275,16 @@ class WalletManager : } override fun reconcile(message: WalletManagerReconcileMessage) { - logDebug("reconcile: $message") - ioScope.launch { - mainScope.launch { apply(message) } + mainScope.launch { + logDebug("reconcile: $message") + apply(message) } } override fun reconcileMany(messages: List) { - logDebug("reconcile_messages: ${messages.size} messages") - ioScope.launch { - mainScope.launch { messages.forEach { apply(it) } } + mainScope.launch { + logDebug("reconcile_messages: ${messages.size} messages") + messages.forEach { apply(it) } } } diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index a95af0c64..4c83a4bbb 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -28,7 +28,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana // cached transaction details var transactionDetails: [TxId: TransactionDetails] = [:] - public init(id: WalletId) throws { + init(id: WalletId) throws { self.id = id let rust = try RustWalletManager(id: id) @@ -41,7 +41,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana rust.listenForUpdates(reconciler: WeakReconciler(self)) } - public init(xpub: String) throws { + init(xpub: String) throws { let rust = try RustWalletManager.tryNewFromXpub(xpub: xpub) let metadata = rust.walletMetadata() @@ -53,7 +53,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana rust.listenForUpdates(reconciler: WeakReconciler(self)) } - public init(tapSigner: TapSigner, deriveInfo: DeriveInfo, backup: Data? = nil) throws { + init(tapSigner: TapSigner, deriveInfo: DeriveInfo, backup: Data? = nil) throws { let rust = try RustWalletManager.tryNewFromTapSigner( tapSigner: tapSigner, deriveInfo: deriveInfo, backup: backup ) @@ -154,8 +154,13 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana self.loadState = .scanning(txns) case let .availableTransactions(txns): - if self.loadState == .loading { + switch self.loadState { + case .loading, .scanning: + self.loadState = .scanning(txns) + case let .loaded(current) where txns.count > current.count: self.loadState = .scanning(txns) + case .loaded: + break } case let .updatedTransactions(txns): @@ -214,37 +219,31 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana } func reconcile(message: Message) { - rustBridge.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { Log.error("WalletManager no longer available") return } - logger.debug("reconcile: \(message)") - DispatchQueue.main.async { [weak self] in - self?.apply(message) - } + apply(message) } } func reconcileMany(messages: [Message]) { - rustBridge.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { Log.error("WalletManager no longer available") return } - logger.debug("reconcile_messages: \(messages)") - DispatchQueue.main.async { [weak self] in - for message in messages { - self?.apply(message) - } + for message in messages { + apply(message) } } } - public func dispatch(action: Action) { dispatch(action) } - public func dispatch(_ action: Action) { + func dispatch(action: Action) { dispatch(action) } + func dispatch(_ action: Action) { rustBridge.async { [weak self] in self?.logger.debug("dispatch: \(action)") self?.rust.dispatch(action: action) @@ -252,7 +251,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana } // PREVIEW only - public init(preview: String, _ walletMetadata: WalletMetadata? = nil) { + init(preview: String, _ walletMetadata: WalletMetadata? = nil) { assert(preview == "preview_only") id = WalletId() diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 5b9752a4d..3b51d293f 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -244,11 +244,19 @@ impl RustWalletManager { let id = metadata.id.clone(); let wallet = Wallet::try_load_persisted(id.clone())?; let metadata = wallet.metadata.clone(); + + // read cached and send to UI immediately + let cached_balance: Balance = wallet.balance(); + let cached_transactions: Vec = wallet.transactions(); + + let reconciler = MessageSender::new(sender.clone()); + reconciler.send(Message::WalletBalanceChanged(cached_balance.into())); + reconciler.send(Message::AvailableTransactions(cached_transactions)); + let actor = task::spawn_actor(WalletActor::new(wallet, sender.clone())); - // only creates the scanner if its not already complet - let scanner = - WalletScanner::try_new(metadata.clone(), sender.clone()).ok().map(spawn_actor); + // will only create the scanner if its not already complete + let scanner = WalletScanner::try_new(metadata.clone(), sender).ok().map(spawn_actor); let label_manager = LabelManager::new(id.clone()).into(); @@ -256,7 +264,7 @@ impl RustWalletManager { id, actor, metadata: Arc::new(RwLock::new(metadata)), - reconciler: MessageSender::new(sender), + reconciler, reconcile_receiver: Arc::new(receiver), label_manager, scanner, diff --git a/rust/src/wallet.rs b/rust/src/wallet.rs index c24e889dc..135e1d160 100644 --- a/rust/src/wallet.rs +++ b/rust/src/wallet.rs @@ -511,6 +511,27 @@ impl Wallet { self.bdk.balance().into() } + /// Read cached transactions from the BDK wallet + pub fn transactions(&self) -> Vec { + use crate::transaction::{Amount, Transaction}; + + let zero = Amount::ZERO; + + let mut transactions = self + .bdk + .transactions() + .map(|tx| { + let sent_and_received = self.bdk.sent_and_received(&tx.tx_node.tx).into(); + (tx, sent_and_received) + }) + .map(|(tx, sent_and_received)| Transaction::new(&self.id, sent_and_received, tx)) + .filter(|tx| tx.sent_and_received().amount() > zero) + .collect::>(); + + transactions.sort_unstable_by(|a, b| a.cmp(b).reverse()); + transactions + } + #[allow(dead_code)] pub fn public_external_descriptor(&self) -> crate::keys::Descriptor { let extended_descriptor: ExtendedDescriptor = From 70541437133cf70a9cfbffaaf35675b8907353c0 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 20:51:26 -0600 Subject: [PATCH 90/97] Update balance when fiat rates change --- android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt | 1 + android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt | 2 +- ios/Cove/AppManager.swift | 1 + ios/Cove/WalletManager.swift | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) 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 e74b50911..870fcefa8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -548,6 +548,7 @@ class AppManager private constructor() : FfiReconcile { is AppStateReconcileMessage.FiatPricesChanged -> { prices = message.v1 + walletManager?.updateFiatBalance() } is AppStateReconcileMessage.FeesChanged -> { 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 4e91dfbac..961649f46 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -180,7 +180,7 @@ class WalletManager : transactionDetailsCache[txId] = details } - private fun updateFiatBalance() { + internal fun updateFiatBalance() { fiatBalance = rust.amountInFiat(balance.spendable()) } diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index d98bba5f5..0cd612e94 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -239,6 +239,7 @@ import SwiftUI case let .fiatPricesChanged(prices): self.prices = prices + self.walletManager?.updateFiatBalance() case let .feesChanged(fees): self.fees = fees diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index 4c83a4bbb..167705921 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -133,7 +133,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana return details } - private func updateFiatBalance() { + func updateFiatBalance() { fiatBalance = rust.amountInFiat(amount: balance.spendable()) } From c55dde68d80f6911b7cec730705c232266c328c9 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 4 Dec 2025 20:51:40 -0600 Subject: [PATCH 91/97] Reduce loading delays on `load_and_reset` --- .../org/bitcoinppl/cove/SelectedWalletContainer.kt | 12 ------------ ios/CoveCore/Sources/CoveCore/generated/cove.swift | 6 +++--- rust/src/app.rs | 6 +++--- rust/src/router.rs | 9 ++++++--- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt index aaac23b84..d0d47b9d8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/SelectedWalletContainer.kt @@ -27,12 +27,6 @@ import org.bitcoinppl.cove.wallet_transactions.WalletTransactionsScreen import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.types.* -// delay to allow UI to settle before updating balance -private const val BALANCE_UPDATE_DELAY_MS = 500L - -// delay before starting wallet scan to allow initial load to complete -private const val WALLET_SCAN_DELAY_MS = 400L - // delay before showing export loading alert private const val EXPORT_LOADING_ALERT_DELAY_MS = 500L @@ -77,9 +71,6 @@ fun SelectedWalletContainer( if (isActive && requestedId == id) { manager = wm loadedId = requestedId - - // small delay then update balance - delay(BALANCE_UPDATE_DELAY_MS) wm.updateWalletBalance() } else { android.util.Log.d(tag, "discarding stale wallet load for $requestedId, now loading $id") @@ -107,9 +98,6 @@ fun SelectedWalletContainer( LaunchedEffect(manager) { manager?.let { wm -> try { - // small delay and then start scanning wallet - delay(WALLET_SCAN_DELAY_MS) - if (!isActive) return@LaunchedEffect wm.rust.getTransactions() wm.updateWalletBalance() wm.rust.startWalletScan() diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 99697ee98..146a581d6 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -2643,7 +2643,7 @@ public protocol FfiAppProtocol: AnyObject, Sendable { func listenForUpdates(updater: FfiReconcile) /** - * Load and reset the default route after 800ms delay + * Load and reset the default route after 200ms delay */ func loadAndResetDefaultRoute(route: Route) @@ -2933,7 +2933,7 @@ open func listenForUpdates(updater: FfiReconcile) {try! rustCall() { } /** - * Load and reset the default route after 800ms delay + * Load and reset the default route after 200ms delay */ open func loadAndResetDefaultRoute(route: Route) {try! rustCall() { uniffi_cove_fn_method_ffiapp_load_and_reset_default_route( @@ -28243,7 +28243,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_listen_for_updates() != 31459) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 39591) { + if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 65360) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route_after() != 60004) { diff --git a/rust/src/app.rs b/rust/src/app.rs index 5aad04db2..d199d6d87 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -18,7 +18,7 @@ use crate::{ keychain::Keychain, network::Network, node::Node, - router::{Route, RouteFactory, Router}, + router::{LOAD_AND_RESET_DELAY_MS, Route, RouteFactory, Router}, wallet::metadata::{WalletId, WalletMetadata, WalletType}, }; use cove_macros::impl_default_for; @@ -405,9 +405,9 @@ impl FfiApp { .collect::>() } - /// Load and reset the default route after 800ms delay + /// Load and reset the default route after default delay pub fn load_and_reset_default_route(&self, route: Route) { - self.load_and_reset_default_route_after(route, 800); + self.load_and_reset_default_route_after(route, LOAD_AND_RESET_DELAY_MS); } /// Load and reset the default route diff --git a/rust/src/router.rs b/rust/src/router.rs index efb46639e..4622ded43 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -1,5 +1,8 @@ use std::sync::Arc; +/// Default delay for load-and-reset transitions (in milliseconds) +pub const LOAD_AND_RESET_DELAY_MS: u32 = 250; + use crate::{ app::FfiApp, database::Database, @@ -226,7 +229,7 @@ impl From for Route { impl Route { pub fn load_and_reset(self) -> Self { - self.load_and_reset_after(800) + self.load_and_reset_after(LOAD_AND_RESET_DELAY_MS) } pub fn load_and_reset_after(self, time: u32) -> Self { @@ -300,11 +303,11 @@ impl RouteFactory { routes.push(BoxedRoute::new(default_route).into()); routes.extend(boxed_nested_routes); - Route::LoadAndReset { reset_to: routes, after_millis: 500 } + Route::LoadAndReset { reset_to: routes, after_millis: LOAD_AND_RESET_DELAY_MS } } pub fn load_and_reset_to(&self, reset_to: Route) -> Route { - Self::load_and_reset_to_after(self, reset_to, 500) + Self::load_and_reset_to_after(self, reset_to, LOAD_AND_RESET_DELAY_MS) } pub fn load_and_reset_to_after(&self, reset_to: Route, time: u32) -> Route { From 21ee671511230763dadc9b0e3b7c08861451315a Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 09:58:11 -0600 Subject: [PATCH 92/97] Unify wallet mode change delay to 250ms --- android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt | 4 +++- ios/Cove/AppManager.swift | 4 +++- ios/CoveCore/Sources/CoveCore/generated/cove.swift | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) 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 870fcefa8..a5efdae17 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -1,5 +1,7 @@ package org.bitcoinppl.cove +private const val WALLET_MODE_CHANGE_DELAY_MS = 250L + import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -575,7 +577,7 @@ class AppManager private constructor() : FfiReconcile { isLoading = true loadWallets() launch { - kotlinx.coroutines.delay(200) + kotlinx.coroutines.delay(WALLET_MODE_CHANGE_DELAY_MS) isLoading = false } } diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index 0cd612e94..053a77f08 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -1,6 +1,8 @@ import Observation import SwiftUI +private let walletModeChangeDelayMs = 250 + @Observable final class AppManager: FfiReconcile { static let shared = AppManager() @@ -263,7 +265,7 @@ import SwiftUI self.loadWallets() Task { - try? await Task.sleep(for: .milliseconds(200)) + try? await Task.sleep(for: .milliseconds(walletModeChangeDelayMs)) await MainActor.run { withAnimation { self.isLoading = false } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 146a581d6..2c5ff5512 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -2643,7 +2643,7 @@ public protocol FfiAppProtocol: AnyObject, Sendable { func listenForUpdates(updater: FfiReconcile) /** - * Load and reset the default route after 200ms delay + * Load and reset the default route after default delay */ func loadAndResetDefaultRoute(route: Route) @@ -2933,7 +2933,7 @@ open func listenForUpdates(updater: FfiReconcile) {try! rustCall() { } /** - * Load and reset the default route after 200ms delay + * Load and reset the default route after default delay */ open func loadAndResetDefaultRoute(route: Route) {try! rustCall() { uniffi_cove_fn_method_ffiapp_load_and_reset_default_route( @@ -28243,7 +28243,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_listen_for_updates() != 31459) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 65360) { + if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 12168) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route_after() != 60004) { From 317e59b8f63669d529baf3a455d5b51a32562221 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Fri, 5 Dec 2025 11:13:05 -0600 Subject: [PATCH 93/97] Add custom keyboard toolbar for iOS --- ios/Cove.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 11 +- .../KeyboardAccessoryHost.swift | 49 ++++++- ios/Cove/Extention/KeyboardObserver.swift | 51 +++++-- .../HotWallet/HotWalletImportCard.swift | 79 ++++++++++- .../HotWallet/HotWalletImportScreen.swift | 127 ++++++++---------- 6 files changed, 237 insertions(+), 97 deletions(-) diff --git a/ios/Cove.xcodeproj/project.pbxproj b/ios/Cove.xcodeproj/project.pbxproj index 2650f752d..b0654ebec 100644 --- a/ios/Cove.xcodeproj/project.pbxproj +++ b/ios/Cove.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AC85E3B52EE343C300BCEE4B /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = AC85E3B42EE343C300BCEE4B /* SwiftUIIntrospect */; }; AC9AB5792C34956C00BAE3BE /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9AB5782C34956C00BAE3BE /* Logger.swift */; }; ACA4CBEF2C2365430008F63E /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = ACA4CBEE2C2365430008F63E /* KeychainSwift */; }; ACC61E2F2ECBD8BE00D13947 /* MijickPopups in Frameworks */ = {isa = PBXBuildFile; productRef = ACC61E2E2ECBD8BE00D13947 /* MijickPopups */; }; @@ -54,6 +55,7 @@ ACD0053C2DB5309A003DC493 /* CoveCore in Frameworks */, ACA4CBEF2C2365430008F63E /* KeychainSwift in Frameworks */, ACC61E2F2ECBD8BE00D13947 /* MijickPopups in Frameworks */, + AC85E3B52EE343C300BCEE4B /* SwiftUIIntrospect in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,6 +105,7 @@ ACA4CBEE2C2365430008F63E /* KeychainSwift */, ACD0053B2DB5309A003DC493 /* CoveCore */, ACC61E2E2ECBD8BE00D13947 /* MijickPopups */, + AC85E3B42EE343C300BCEE4B /* SwiftUIIntrospect */, ); productName = Cove; productReference = D0245F4C2C0F7B4E0042B447 /* Cove.app */; @@ -136,6 +139,7 @@ ACA4CBED2C2365430008F63E /* XCRemoteSwiftPackageReference "keychain-swift" */, ACD0053A2DB5309A003DC493 /* XCLocalSwiftPackageReference "CoveCore" */, ACC61E2D2ECBD8BE00D13947 /* XCRemoteSwiftPackageReference "Popups" */, + AC85E3B32EE343C300BCEE4B /* XCRemoteSwiftPackageReference "swiftui-introspect" */, ); productRefGroup = D0245F4D2C0F7B4E0042B447 /* Products */; projectDirPath = ""; @@ -424,6 +428,14 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ + AC85E3B32EE343C300BCEE4B /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/swiftui-introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 26.0.0; + }; + }; ACA4CBED2C2365430008F63E /* XCRemoteSwiftPackageReference "keychain-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/evgenyneu/keychain-swift"; @@ -443,6 +455,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + AC85E3B42EE343C300BCEE4B /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = AC85E3B32EE343C300BCEE4B /* XCRemoteSwiftPackageReference "swiftui-introspect" */; + productName = SwiftUIIntrospect; + }; ACA4CBEE2C2365430008F63E /* KeychainSwift */ = { isa = XCSwiftPackageProductDependency; package = ACA4CBED2C2365430008F63E /* XCRemoteSwiftPackageReference "keychain-swift" */; diff --git a/ios/Cove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Cove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6f62fb5a1..37c33f68e 100644 --- a/ios/Cove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Cove.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3ebfc8997f2b1d89f9a79ff66bba6d2fd72582adefe36abedc649d1a24f6e81b", + "originHash" : "7dc70b627139f1c39cf1106cee9dea530ccdb9170e529e76bf86497747d6fd29", "pins" : [ { "identity" : "keychain-swift", @@ -18,6 +18,15 @@ "revision" : "3aa55233eda7bbcbd98b52a6f63d5a7e1955189a", "version" : "4.0.5" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect", + "state" : { + "revision" : "a08b87f96b41055577721a6e397562b21ad52454", + "version" : "26.0.0" + } } ], "version" : 3 diff --git a/ios/Cove/Components/KeyboardAccessory/KeyboardAccessoryHost.swift b/ios/Cove/Components/KeyboardAccessory/KeyboardAccessoryHost.swift index 68d3fe125..1686e0d73 100644 --- a/ios/Cove/Components/KeyboardAccessory/KeyboardAccessoryHost.swift +++ b/ios/Cove/Components/KeyboardAccessory/KeyboardAccessoryHost.swift @@ -35,16 +35,36 @@ final class KeyboardAccessoryController: ObservableObject { private var container: UIView? private weak var currentResponder: UIView? private var isAttached: Bool = false - private var notificationObserver: NSObjectProtocol? + private var didBecomeActiveObserver: NSObjectProtocol? + private var keyboardDidShowObserver: NSObjectProtocol? + private var keyboardWillHideObserver: NSObjectProtocol? init() { - notificationObserver = NotificationCenter.default.addObserver( + didBecomeActiveObserver = NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in self?.reattachIfNeeded() } + + keyboardDidShowObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reattachOnKeyboardShow() + } + + keyboardWillHideObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] _ in + // mark as detached so we know to reattach when keyboard shows again + self?.isAttached = false + self?.currentResponder = nil + } } /// Reattach the accessory view when app returns from background/notification center. @@ -66,8 +86,31 @@ final class KeyboardAccessoryController: ObservableObject { } } + /// Reattach accessory when keyboard appears after focus transitions. + /// Handles the case where first responder changes between text fields. + private func reattachOnKeyboardShow() { + guard let container = self.container else { return } + + // re-capture first responder + UIResponder.captureCurrentFirstResponder(from: nil) + guard let responder = UIResponder.currentFirstResponderView else { return } + + // always reattach when keyboard shows (we track hide state via keyboardWillHide) + // force rebuild: clear first, then re-set + self.setAccessory(on: responder, accessoryView: nil, forceReload: false) + self.setAccessory(on: responder, accessoryView: container, forceReload: true) + self.currentResponder = responder + self.isAttached = true + } + deinit { - if let observer = notificationObserver { + if let observer = didBecomeActiveObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = keyboardDidShowObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = keyboardWillHideObserver { NotificationCenter.default.removeObserver(observer) } } diff --git a/ios/Cove/Extention/KeyboardObserver.swift b/ios/Cove/Extention/KeyboardObserver.swift index bfcfa7368..28108f71b 100644 --- a/ios/Cove/Extention/KeyboardObserver.swift +++ b/ios/Cove/Extention/KeyboardObserver.swift @@ -2,28 +2,51 @@ import Foundation import SwiftUI import UIKit +@Observable @MainActor -class KeyboardObserver: ObservableObject { - @Published var keyboardIsShowing = false +final class KeyboardObserver { + var keyboardIsShowing = false + var keyboardHeight: CGFloat = 0 + + // nonisolated so they can be accessed in deinit + private nonisolated(unsafe) var showObserver: NSObjectProtocol? + private nonisolated(unsafe) var hideObserver: NSObjectProtocol? init() { - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) - } + showObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - @objc func keyboardWillShow() { - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.25)) { - self.keyboardIsShowing = true + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.25)) { + self.keyboardIsShowing = true + self.keyboardHeight = keyboardFrame?.height ?? 0 + } } } - } - @objc func keyboardWillHide() { - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.25)) { - self.keyboardIsShowing = false + hideObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.25)) { + self.keyboardIsShowing = false + self.keyboardHeight = 0 + } } } } + + deinit { + if let showObserver { NotificationCenter.default.removeObserver(showObserver) } + if let hideObserver { NotificationCenter.default.removeObserver(hideObserver) } + } } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift index e9d53e4fc..5b4539a7a 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift @@ -6,8 +6,55 @@ // import SwiftUI +import SwiftUIIntrospect private let rowHeight = 30.0 + +/// Handles return key press without dismissing keyboard, forwarding other calls to original delegate +private final class TextFieldReturnHandler: NSObject, UITextFieldDelegate { + var onReturn: () -> Void + weak var originalDelegate: UITextFieldDelegate? + + init(onReturn: @escaping () -> Void, originalDelegate: UITextFieldDelegate?) { + self.onReturn = onReturn + self.originalDelegate = originalDelegate + } + + func textFieldShouldReturn(_: UITextField) -> Bool { + onReturn() + return false // prevents keyboard dismissal + } + + // forward all other delegate methods to original + func textFieldDidBeginEditing(_ textField: UITextField) { + originalDelegate?.textFieldDidBeginEditing?(textField) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + originalDelegate?.textFieldDidEndEditing?(textField) + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + originalDelegate?.textFieldDidChangeSelection?(textField) + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + originalDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true + } + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + originalDelegate?.textFieldShouldBeginEditing?(textField) ?? true + } + + func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { + originalDelegate?.textFieldShouldEndEditing?(textField) ?? true + } + + func textFieldShouldClear(_ textField: UITextField) -> Bool { + originalDelegate?.textFieldShouldClear?(textField) ?? true + } +} + private let numberOfRows = 6 private let groupsOf = HotWalletImportScreen.GROUPS_OF @@ -111,6 +158,7 @@ private struct AutocompleteField: View { @State private var state: FieldState = .initial @State private var showSuggestions = false @State private var offset: CGPoint = .zero + @State private var returnHandler: TextFieldReturnHandler? private enum FieldState { case initial @@ -190,6 +238,21 @@ private struct AutocompleteField: View { func submitFocusField() { filteredSuggestions = [] + // only advance if word is valid, otherwise stay on current field + let isValid = autocomplete.isValidWord(word: text, allWords: allEnteredWords) + + if text.isEmpty { + state = .typing + return + } + + if !isValid { + state = .invalid + return + } + + state = .valid + let currentFocusField = UInt8(focusField?.fieldNumber ?? 1) let nextFieldNumber = Int( autocomplete.nextFieldNumber( @@ -204,10 +267,6 @@ private struct AutocompleteField: View { tabIndex = Int(nextFieldNumber / groupsOf) } } - - if text == "" { return state = .typing } - if autocomplete.isValidWord(word: text, allWords: allEnteredWords) { return state = .valid } - state = .invalid } var textField: some View { @@ -240,7 +299,17 @@ private struct AutocompleteField: View { state = .invalid } } - .onSubmit { submitFocusField() } + .introspect(.textField, on: .iOS(.v18, .v26)) { textField in + // only set up handler once, capturing original delegate + if returnHandler == nil { + let handler = TextFieldReturnHandler( + onReturn: { [self] in submitFocusField() }, + originalDelegate: textField.delegate + ) + returnHandler = handler + textField.delegate = handler + } + } .onChange(of: text, initial: false) { oldText, newText in text = newText.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift index 1311a5700..e2a993311 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UIKit private let groupsOf = HotWalletImportScreen.GROUPS_OF @@ -78,10 +77,9 @@ struct HotWalletImportScreen: View { @Environment(AppManager.self) private var app @State private var tabIndex: Int = 0 - @State private var hasStartedTyping: Bool = false @FocusState var focusField: ImportFieldNumber? - @StateObject private var keyboardAccessoryController: KeyboardAccessoryController = .init() + @State private var keyboardObserver = KeyboardObserver() private var accessoryHeight: CGFloat { switch sizeCategory { @@ -99,10 +97,6 @@ struct HotWalletImportScreen: View { return min(maxWidth, target) } - private var needsCustomToolbar: Bool { - if #available(iOS 26, *) { true } else { false } - } - @State var manager: ImportWalletManager = .init() @State private var validator: WordValidator? = nil @@ -309,19 +303,6 @@ struct HotWalletImportScreen: View { @ToolbarContentBuilder var ToolbarContent: some ToolbarContent { - if !needsCustomToolbar, hasStartedTyping { - ToolbarItem(placement: .keyboard) { - KeyboardAutoCompleteView - .foregroundStyle(.primary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .opacity(!filteredSuggestions.isEmpty ? 1 : 0) - .frame(minWidth: accessoryMinWidth, alignment: .center) - .animation(.easeInOut(duration: 0.2), value: !filteredSuggestions.isEmpty) - .allowsHitTesting(!filteredSuggestions.isEmpty) - } - } - ToolbarItem(placement: .principal) { Text("Import Wallet") .font(.callout) @@ -347,7 +328,41 @@ struct HotWalletImportScreen: View { } var body: some View { - MainContent + GeometryReader { geometry in + let bottomSafeArea = geometry.safeAreaInsets.bottom + + ZStack(alignment: .bottom) { + MainContent + + if keyboardObserver.keyboardIsShowing, focusField != nil, !filteredSuggestions.isEmpty { + KeyboardToolbar + .offset(y: -(keyboardObserver.keyboardHeight - bottomSafeArea)) + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: filteredSuggestions.isEmpty) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + .ignoresSafeArea(.keyboard) + } + + @ViewBuilder + private var KeyboardToolbar: some View { + HStack { + ForEach(filteredSuggestions, id: \.self) { word in + Spacer() + Button(word) { selectWordInKeyboard(word) } + .foregroundColor(.primary) + Spacer() + + if filteredSuggestions.count > 1, filteredSuggestions.last != word { + Divider() + } + } + } + .frame(maxWidth: .infinity) + .frame(height: accessoryHeight) + .modifier(KeyboardToolbarStyleModifier()) } @ViewBuilder @@ -364,7 +379,9 @@ struct HotWalletImportScreen: View { @ViewBuilder var MainContent: some View { VStack { - Spacer() + if !keyboardObserver.keyboardIsShowing { + Spacer() + } if isMiniDeviceOrLargeText(sizeCategory) { ScrollView { @@ -384,6 +401,7 @@ struct HotWalletImportScreen: View { ImportButton } + .animation(.easeInOut(duration: 0.25), value: keyboardObserver.keyboardIsShowing) .padding() .padding(.bottom, 24) .toolbar { ToolbarContent } @@ -415,7 +433,6 @@ struct HotWalletImportScreen: View { } .onChange(of: focusField, initial: false, onChangeFocusField) .onChange(of: nfcReader.scannedMessage, initial: false, onChangeNfcMessage) - .onChange(of: enteredWords, initial: false, onChangeEnteredWords) .onDisappear { focusField = nil nfcReader.resetReader() @@ -433,40 +450,6 @@ struct HotWalletImportScreen: View { .opacity(0.5) ) .background(Color.midnightBlue) - .background { - if needsCustomToolbar { - KeyboardAccessoryHost( - controller: keyboardAccessoryController, - isVisible: focusField != nil, - height: accessoryHeight - ) { - HStack(spacing: 0) { - Spacer(minLength: 8) - ZStack { - Capsule() - .fill(.thickMaterial) - .frame(minWidth: accessoryMinWidth, minHeight: accessoryHeight) - .shadow(color: Color.black.opacity(0.18), radius: 8, x: 0, y: -1) - - KeyboardAutoCompleteView - .foregroundStyle(.primary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .opacity(!filteredSuggestions.isEmpty ? 1 : 0) - .frame(minWidth: accessoryMinWidth, alignment: .center) - .animation(.easeInOut(duration: 0.2), value: !filteredSuggestions.isEmpty) - .allowsHitTesting(!filteredSuggestions.isEmpty) - } - - Spacer(minLength: 8) - } - .padding(.horizontal, 12) // native toolbar side insets - .frame(height: accessoryHeight, alignment: .center) - .background(Color.midnightBlue) - .contentShape(Rectangle()) - } - } - } .tint(.white) } @@ -583,23 +566,6 @@ struct HotWalletImportScreen: View { )) } - func onChangeEnteredWords(_: [[String]], _ new: [[String]]) { - // early exit if already set to true - if hasStartedTyping { - return - } - - // check if user has started typing - for group in new { - for word in group { - if !word.isEmpty { - hasStartedTyping = true - return - } - } - } - } - func setWords(_ words: [[String]]) { let numberOfWords = words.compactMap(\.count).reduce(0, +) switch numberOfWords { @@ -625,6 +591,19 @@ struct HotWalletImportScreen: View { } } +private struct KeyboardToolbarStyleModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .background(Capsule().fill(.thickMaterial)) + .padding(.horizontal, 8) + } else { + content + .background(Color(UIColor.secondarySystemBackground)) + } + } +} + private struct DuplicateWalletItem: Identifiable { var id: UUID var walletId: WalletId From 6fd3492316658c887d0147a0dff28e8d970587d2 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Mon, 8 Dec 2025 14:37:54 -0600 Subject: [PATCH 94/97] Bump iOS project version to 49 --- ios/Cove.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Cove.xcodeproj/project.pbxproj b/ios/Cove.xcodeproj/project.pbxproj index b0654ebec..39c90bd53 100644 --- a/ios/Cove.xcodeproj/project.pbxproj +++ b/ios/Cove.xcodeproj/project.pbxproj @@ -306,7 +306,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; @@ -356,7 +356,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; From d7b5096ab89859e1fd4dcbb336154af8342e6425 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 11 Dec 2025 10:47:29 -0600 Subject: [PATCH 95/97] Update KeyboardObserver.swift --- ios/Cove/Extention/KeyboardObserver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Cove/Extention/KeyboardObserver.swift b/ios/Cove/Extention/KeyboardObserver.swift index 28108f71b..22bb47f41 100644 --- a/ios/Cove/Extention/KeyboardObserver.swift +++ b/ios/Cove/Extention/KeyboardObserver.swift @@ -33,7 +33,7 @@ final class KeyboardObserver { forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main - ) { [weak self] notification in + ) { [weak self] _ in guard let self else { return } Task { @MainActor in From c7c7a1c30888b1036dfa6e1bfdfc6e2b08d2006c Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 11 Dec 2025 10:57:50 -0600 Subject: [PATCH 96/97] Rebuild --- android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 6acb59fb8..7fe08c07f 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 @@ -3324,7 +3324,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_ffiapp_listen_for_updates() != 31459.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 39591.toShort()) { + if (lib.uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route() != 12168.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_ffiapp_load_and_reset_default_route_after() != 60004.toShort()) { @@ -8910,7 +8910,7 @@ public interface FfiAppInterface { fun `listenForUpdates`(`updater`: FfiReconcile) /** - * Load and reset the default route after 800ms delay + * Load and reset the default route after default delay */ fun `loadAndResetDefaultRoute`(`route`: Route) @@ -9316,7 +9316,7 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface /** - * Load and reset the default route after 800ms delay + * Load and reset the default route after default delay */override fun `loadAndResetDefaultRoute`(`route`: Route) = callWithHandle { From 80270c61fe616d3aabd4dd6460e0a9d740315e07 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Thu, 11 Dec 2025 11:03:55 -0600 Subject: [PATCH 97/97] Fix merge conflicts --- ios/Cove/WalletManager.swift | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index 32c940394..301107bc8 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -37,7 +37,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana walletMetadata = rust.walletMetadata() unsignedTransactions = (try? rust.getUnsignedTransactions()) ?? [] - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() rust.listenForUpdates(reconciler: WeakReconciler(self)) } @@ -49,7 +49,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana walletMetadata = metadata id = metadata.id - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() rust.listenForUpdates(reconciler: WeakReconciler(self)) } @@ -137,22 +137,16 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana transactionDetails[txId] = details } - private func updateFiatBalance() async { - do { - let fiatBalance = try await rust.balanceInFiat() - await MainActor.run { - withAnimation { self.fiatBalance = fiatBalance } - } - } catch { - Log.error("error getting fiat balance: \(error)") - fiatBalance = 0.00 - } + public func updateFiatBalance() { + fiatBalance = rust.amountInFiat(amount: balance.spendable()) } func updateWalletBalance() async { let balance = await rust.balance() - await MainActor.run { self.balance = balance } - await updateFiatBalance() + await MainActor.run { + self.balance = balance + self.updateFiatBalance() + } } func apply(_ message: Message) { @@ -181,7 +175,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana case let .walletBalanceChanged(balance): withAnimation { self.balance = balance } - Task { [weak self] in await self?.updateFiatBalance() } + updateFiatBalance() case .unsignedTransactionsChanged: self.unsignedTransactions = (try? rust.getUnsignedTransactions()) ?? []