diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 891fe7f..e170fab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.activity:activity-compose") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") implementation("androidx.constraintlayout:constraintlayout-compose:1.1.1") implementation("androidx.navigation:navigation-compose:2.9.3") @@ -86,6 +87,17 @@ dependencies { // QR codes implementation("com.google.zxing:core:3.5.3") + // CameraX (needed by QrCodeScanner) + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + + // ML Kit barcode scanning (needed by QrCodeScanner) + implementation("com.google.mlkit:barcode-scanning:17.3.0") + + // Accompanist permissions (runtime permission handling in Compose) + implementation("com.google.accompanist:accompanist-permissions:0.36.0") + // Tests testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.3.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db99496..7281a00 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:android="http://schemas.android.com/apk/res/android"> + + () + + val net = wallet.network() + val tempFiles = mutableListOf() + + for (descString in candidates) { + val label = descString.substringBefore("(") + try { + val descriptor = Descriptor(descString, net) + + val tempDir = System.getProperty("java.io.tmpdir") + val tempFile = File( + "$tempDir/temp-${UUID.randomUUID().toString().take(8)}.sqlite3", + ) + tempFiles.add(tempFile) + val tempConnection = Persister.newSqlite(tempFile.absolutePath) + + val tempBdkWallet = BdkWallet.createSingle( + descriptor = descriptor, + network = net, + persister = tempConnection, + ) + + DwLogger.log(INFO, "Sweep: scanning $label ...") + val fullScanRequest = tempBdkWallet.startFullScan().build() + val update = currentBlockchainClient?.fullScan( + fullScanRequest = fullScanRequest, + stopGap = 3u, + ) ?: throw IllegalStateException("Blockchain client not initialized") + tempBdkWallet.applyUpdate(update) + + val bdkBalance = tempBdkWallet.balance() + val balance = bdkBalance.total.toSat() + DwLogger.log( + INFO, + "Sweep: $label balance: " + + "confirmed=${bdkBalance.confirmed.toSat()} " + + "pending=${bdkBalance.untrustedPending.toSat()} " + + "total=$balance", + ) + Log.i(TAG, "Balance for $label: $balance") + + if (balance > maxBalance) { + maxBalance = balance + bestWallet = tempBdkWallet + } + } catch (e: Exception) { + val msg = "$label failed: ${e.javaClass.simpleName} — ${e.message}" + Log.e(TAG, msg, e) + DwLogger.log(ERROR, "Sweep: $msg") + candidateErrors.add(msg) + } + } + + try { + if (bestWallet == null || maxBalance == 0UL) { + val detail = if (candidateErrors.isNotEmpty()) { + candidateErrors.joinToString("; ") + } else { + "All script types returned 0 balance" + } + throw IllegalStateException( + "No sweepable funds found for this WIF ($detail)", + ) + } + + DwLogger.log(INFO, "Sweep: found $maxBalance sats, building tx") + val destinationAddress = getNewAddress().address + val destinationScriptPubKey = destinationAddress.scriptPubkey() + + val txBuilder = TxBuilder() + .drainWallet() + .drainTo(destinationScriptPubKey) + .feeRate(feeRate) + + val psbt = txBuilder.finish(bestWallet) + val signed = bestWallet.sign(psbt) + if (!signed) { + throw IllegalStateException( + "Failed to sign sweep transaction with the temporary wallet", + ) + } + + DwLogger.log(INFO, "Sweep: signed successfully") + return psbt + } finally { + for (f in tempFiles) { + listOf(f, File("${f.absolutePath}-wal"), File("${f.absolutePath}-shm")) + .forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + } } private fun getAllTransactions(): List = wallet.transactions() diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt new file mode 100644 index 0000000..59d35a2 --- /dev/null +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain.utils + +import android.util.Log +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.WARN + +private const val TAG = "WifParser" + +/** + * Utility for detecting and extracting WIF-encoded private keys from various input formats. + */ +object WifParser { + private val WIF_FIRST_CHARS = setOf('5', 'K', 'L', '9', 'c') + + private val BASE58_CHARSET: Set = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toSet() + + private val WIF_PARAM_KEYS = setOf("wif", "privkey", "private_key", "privatekey") + + fun extract(value: String): String? { + val trimmed = value.trim() + val candidates = mutableListOf() + + candidates.add(trimmed) + + if (trimmed.lowercase().startsWith("wif:")) { + candidates.add(trimmed.drop(4).trim()) + } + + if (trimmed.lowercase().startsWith("bitcoin:")) { + try { + val queryPart = trimmed.substringAfter("?", "") + if (queryPart.isNotEmpty()) { + queryPart.split("&").forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size == 2 && kv[0].lowercase() in WIF_PARAM_KEYS) { + candidates.add(kv[1].trim()) + } + } + } + } catch (e: Throwable) { + Log.i(TAG, "WIF Parsing error: ${e.message}", e) + } + } + + val result = candidates.firstOrNull { isLikelyWif(it) } + if (result != null) { + DwLogger.log( + INFO, + "WifParser: extracted WIF (length=${result.length}) from input (length=${trimmed.length})" + ) + } else if (candidates.size > 1) { + DwLogger.log( + WARN, + "WifParser: no valid WIF found in ${candidates.size} candidates from input (length=${trimmed.length})" + ) + candidates.forEach { c -> + DwLogger.log( + WARN, + "WifParser: candidate length=${c.length}, first='${c.firstOrNull()}', likelyWif=${isLikelyWif(c)}" + ) + } + } + return result + } + + fun isLikelyWif(value: String): Boolean { + if (value.length != 51 && value.length != 52) return false + + val first = value.firstOrNull() ?: return false + if (first !in WIF_FIRST_CHARS) return false + + return value.all { it in BASE58_CHARSET } + } +} diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt index c5b35ab..c4b5aa0 100644 --- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt @@ -126,7 +126,14 @@ fun WalletNavigation(drawerState: DrawerState, activeWallet: Wallet, walletViewM animationSpec = tween(ANIMATION_DURATION) ) } - ) { SendScreen(onAction = sendViewModel::onAction, navController = navController) } + ) { + SendScreen( + onAction = sendViewModel::onAction, + broadcastResult = sendViewModel.broadcastResult, + clearBroadcastResult = sendViewModel::clearBroadcastResult, + navController = navController, + ) + } composable( enterTransition = { diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt index 220ea19..6c32b52 100644 --- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt @@ -53,6 +53,7 @@ import org.bitcoindevkit.devkitwallet.domain.DwLogger import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO import org.bitcoindevkit.devkitwallet.domain.bip39WordList import org.bitcoindevkit.devkitwallet.domain.createScriptAppropriateDescriptor +import org.bitcoindevkit.devkitwallet.domain.utils.WifParser import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors import org.bitcoindevkit.devkitwallet.presentation.theme.monoRegular @@ -200,38 +201,47 @@ internal fun RecoverWalletScreen(onAction: (WalletCreateType) -> Unit, navContro } } if (recoveryPhrase.isNotEmpty()) { - Log.i("RecoverWalletScreen", "Recovering wallet with recovery phrase") - val parsingResult = parseRecoveryPhrase(recoveryPhrase) - - if (parsingResult is RecoveryPhraseValidationResult.Invalid) { + if (WifParser.extract(recoveryPhrase) != null) { scope.launch { - snackbarHostState.showSnackbar(parsingResult.reason) + snackbarHostState.showSnackbar( + "This looks like a WIF private key, not a recovery phrase. " + + "To sweep a WIF, open an existing wallet and use Send > Scan QR." + ) + } + } else { + Log.i("RecoverWalletScreen", "Recovering wallet with recovery phrase") + val parsingResult = parseRecoveryPhrase(recoveryPhrase) + + if (parsingResult is RecoveryPhraseValidationResult.Invalid) { + scope.launch { + snackbarHostState.showSnackbar(parsingResult.reason) + } + } else if (parsingResult is RecoveryPhraseValidationResult.ProbablyValid) { + val mnemonic = Mnemonic.fromString(parsingResult.recoveryPhrase) + val bip32ExtendedRootKey = DescriptorSecretKey(selectedNetwork.value, mnemonic, null) + val descriptor = createScriptAppropriateDescriptor( + scriptType = selectedScriptType.value, + bip32ExtendedRootKey = bip32ExtendedRootKey, + network = selectedNetwork.value, + keychain = KeychainKind.EXTERNAL + ) + val changeDescriptor = createScriptAppropriateDescriptor( + scriptType = selectedScriptType.value, + bip32ExtendedRootKey = bip32ExtendedRootKey, + network = selectedNetwork.value, + keychain = KeychainKind.INTERNAL + ) + val recoverWalletConfig = RecoverWalletConfig( + name = walletName, + network = selectedNetwork.value, + scriptType = selectedScriptType.value, + descriptor = descriptor, + changeDescriptor = changeDescriptor, + recoveryPhrase = parsingResult.recoveryPhrase + ) + DwLogger.log(INFO, "Recovering wallet with recovery phrase (name: $walletName)") + onAction(WalletCreateType.RECOVER(recoverWalletConfig)) } - } else if (parsingResult is RecoveryPhraseValidationResult.ProbablyValid) { - val mnemonic = Mnemonic.fromString(parsingResult.recoveryPhrase) - val bip32ExtendedRootKey = DescriptorSecretKey(selectedNetwork.value, mnemonic, null) - val descriptor = createScriptAppropriateDescriptor( - scriptType = selectedScriptType.value, - bip32ExtendedRootKey = bip32ExtendedRootKey, - network = selectedNetwork.value, - keychain = KeychainKind.EXTERNAL - ) - val changeDescriptor = createScriptAppropriateDescriptor( - scriptType = selectedScriptType.value, - bip32ExtendedRootKey = bip32ExtendedRootKey, - network = selectedNetwork.value, - keychain = KeychainKind.INTERNAL - ) - val recoverWalletConfig = RecoverWalletConfig( - name = walletName, - network = selectedNetwork.value, - scriptType = selectedScriptType.value, - descriptor = descriptor, - changeDescriptor = changeDescriptor, - recoveryPhrase = parsingResult.recoveryPhrase - ) - DwLogger.log(INFO, "Recovering wallet with recovery phrase (name: $walletName)") - onAction(WalletCreateType.RECOVER(recoverWalletConfig)) } } if (descriptorString.isNotEmpty() && changeDescriptorString.isNotEmpty()) { diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt new file mode 100644 index 0000000..2d4d279 --- /dev/null +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +@file:Suppress("UnsafeOptInUsageError") + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import android.Manifest +import android.util.Log +import android.view.ViewGroup +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +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.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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +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.accompanist.permissions.shouldShowRationale +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private const val TAG = "QrCodeScanner" + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun QrScannerDialog(onScanned: (String) -> Unit, onDismiss: () -> Unit) { + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(DevkitWalletColors.primary), + contentAlignment = Alignment.Center, + ) { + when { + cameraPermissionState.status.isGranted -> { + CameraPreview( + onScanned = { rawValue -> + onScanned(rawValue) + } + ) + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent2), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + ) { + Text(text = "Cancel") + } + } + + cameraPermissionState.status.shouldShowRationale -> { + PermissionRationaleContent( + onRequest = { cameraPermissionState.launchPermissionRequest() }, + onDismiss = onDismiss, + ) + } + + else -> { + DisposableEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + onDispose {} + } + PermissionRationaleContent( + onRequest = { cameraPermissionState.launchPermissionRequest() }, + onDismiss = onDismiss, + ) + } + } + } + } +} + +@Composable +private fun PermissionRationaleContent(onRequest: () -> Unit, onDismiss: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + Text( + text = "Camera permission is required to scan QR codes.", + color = DevkitWalletColors.white, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onRequest, + colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent1), + ) { + Text("Grant permission") + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent2), + ) { + Text("Cancel") + } + } +} + +@Composable +private fun CameraPreview(onScanned: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current + + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + + var scanned by remember { mutableStateOf(false) } + + DisposableEffect(Unit) { + onDispose { cameraExecutor.shutdown() } + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val previewView = PreviewView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener( + { + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val barcodeScanner = BarcodeScanning.getClient() + + val imageAnalysis = ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy -> + if (scanned) { + imageProxy.close() + return@setAnalyzer + } + processImageProxy( + barcodeScanner = barcodeScanner, + imageProxy = imageProxy, + onFound = { rawValue -> + if (!scanned) { + scanned = true + onScanned(rawValue) + } + }, + ) + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis, + ) + } catch (e: Exception) { + Log.e(TAG, "Camera binding failed", e) + } + }, + ContextCompat.getMainExecutor(ctx), + ) + + previewView + }, + ) +} + +private fun processImageProxy( + barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner, + imageProxy: ImageProxy, + onFound: (String) -> Unit, +) { + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return + } + + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + barcodeScanner + .process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + if (barcode.format == Barcode.FORMAT_QR_CODE) { + val rawValue = barcode.rawValue ?: continue + if (rawValue.isNotBlank()) { + onFound(rawValue) + break + } + } + } + }.addOnFailureListener { e -> + Log.w(TAG, "Barcode scan failed", e) + }.addOnCompleteListener { + imageProxy.close() + } +} diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt index 9f1f9ae..e4d59f3 100644 --- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Switch @@ -40,12 +41,16 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,15 +67,19 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.navigation.NavController import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.ScanLine import com.composables.icons.lucide.UserRoundMinus import com.composables.icons.lucide.UserRoundPlus import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.domain.utils.WifParser import org.bitcoindevkit.devkitwallet.presentation.navigation.HomeScreen import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors import org.bitcoindevkit.devkitwallet.presentation.theme.standardText import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.BroadcastResult import org.bitcoindevkit.devkitwallet.presentation.viewmodels.Recipient import org.bitcoindevkit.devkitwallet.presentation.viewmodels.SendScreenAction import org.bitcoindevkit.devkitwallet.presentation.viewmodels.TransactionType @@ -80,7 +89,12 @@ private const val TAG = "SendScreen" @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: NavController) { +internal fun SendScreen( + onAction: (SendScreenAction) -> Unit, + broadcastResult: StateFlow, + clearBroadcastResult: () -> Unit, + navController: NavController, +) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -89,8 +103,47 @@ internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: Nav val (showDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) } val sendAll: MutableState = remember { mutableStateOf(false) } + var showQrScanner by remember { mutableStateOf(false) } + val bottomSheetScaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState() + val result by broadcastResult.collectAsState() + LaunchedEffect(result) { + val r = result ?: return@LaunchedEffect + when (r) { + is BroadcastResult.Success -> { + Toast + .makeText( + context, + "Sent! txid: ${r.txid.take(10)}…", + Toast.LENGTH_LONG + ).show() + } + + is BroadcastResult.Error -> { + Toast + .makeText( + context, + "Broadcast failed: ${r.message}", + Toast.LENGTH_LONG + ).show() + } + } + clearBroadcastResult() + } + + if (showQrScanner) { + QrScannerDialog( + onScanned = { rawValue -> + if (recipientList.isNotEmpty()) { + recipientList[0] = recipientList[0].copy(address = rawValue) + } + showQrScanner = false + }, + onDismiss = { showQrScanner = false }, + ) + } + BottomSheetScaffold( topBar = { SecondaryScreensAppBar( @@ -123,7 +176,10 @@ internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: Nav height = Dimension.fillToConstraints } ) { - TransactionRecipientInput(recipientList = recipientList) + TransactionRecipientInput( + recipientList = recipientList, + onScanClick = { showQrScanner = true }, + ) TransactionAmountInput( recipientList = recipientList, transactionType = if (sendAll.value) TransactionType.SEND_ALL else TransactionType.STANDARD @@ -277,7 +333,7 @@ internal fun AdvancedOptions(sendAll: MutableState, recipientList: Muta } @Composable -private fun TransactionRecipientInput(recipientList: MutableList) { +private fun TransactionRecipientInput(recipientList: MutableList, onScanClick: () -> Unit) { LazyColumn( modifier = Modifier .fillMaxWidth(0.9f) @@ -286,6 +342,16 @@ private fun TransactionRecipientInput(recipientList: MutableList) { itemsIndexed(recipientList) { index, _ -> val recipientAddress: MutableState = rememberSaveable { mutableStateOf("") } + LaunchedEffect(recipientList[index].address) { + if (recipientList[index].address != recipientAddress.value) { + recipientAddress.value = recipientList[index].address + } + } + + val isWif = WifParser.isLikelyWif( + WifParser.extract(recipientAddress.value) ?: recipientAddress.value + ) + Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( modifier = Modifier @@ -298,7 +364,7 @@ private fun TransactionRecipientInput(recipientList: MutableList) { }, label = { Text( - text = "Recipient address ${index + 1}", + text = if (isWif) "WIF key (sweep)" else "Recipient address ${index + 1}", color = DevkitWalletColors.white, ) }, @@ -310,6 +376,15 @@ private fun TransactionRecipientInput(recipientList: MutableList) { unfocusedBorderColor = DevkitWalletColors.white ) ) + if (index == 0) { + IconButton(onClick = onScanClick) { + Icon( + imageVector = Lucide.ScanLine, + contentDescription = "Scan QR code", + tint = DevkitWalletColors.white, + ) + } + } } } } @@ -347,6 +422,10 @@ private fun TransactionAmountInput(recipientList: MutableList, transa itemsIndexed(recipientList) { index, _ -> val amount: MutableState = rememberSaveable { mutableStateOf("") } + val firstAddressIsWif = remember(recipientList[0].address) { + WifParser.isLikelyWif(WifParser.extract(recipientList[0].address) ?: recipientList[0].address) + } + Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( modifier = Modifier @@ -387,7 +466,7 @@ private fun TransactionAmountInput(recipientList: MutableList, transa enabled = ( when (transactionType) { TransactionType.SEND_ALL -> false - else -> true + else -> !firstAddressIsWif } ) ) @@ -453,7 +532,14 @@ private fun Dialog( ) { if (showDialog) { var confirmationText = "Confirm Transaction : \n" - recipientList.forEach { confirmationText += "${it.address}, ${it.amount}\n" } + val rawWif = if (recipientList.isNotEmpty()) WifParser.extract(recipientList[0].address) else null + + if (rawWif != null) { + confirmationText = "Sweep WIF : \n${rawWif.take(8)}...\n" + } else { + recipientList.forEach { confirmationText += "${it.address}, ${it.amount}\n" } + } + if (feeRate.value.isNotEmpty()) { confirmationText += "Fee Rate : ${feeRate.value.toULong()}" } @@ -476,13 +562,30 @@ private fun Dialog( TextButton( onClick = { if (checkRecipientList(recipientList = recipientList, feeRate = feeRate, context = context)) { - val txDataBundle = - TxDataBundle( - recipients = recipientList, + val rawWif = if (recipientList.isNotEmpty()) { + WifParser.extract( + recipientList[0].address + ) + } else { + null + } + if (rawWif != null) { + val txDataBundle = TxDataBundle( + recipients = emptyList(), feeRate = feeRate.value.toULong(), - transactionType = transactionType, + transactionType = TransactionType.SWEEP, + wif = rawWif ) - onAction(SendScreenAction.Broadcast(txDataBundle)) + onAction(SendScreenAction.Broadcast(txDataBundle)) + } else { + val txDataBundle = + TxDataBundle( + recipients = recipientList, + feeRate = feeRate.value.toULong(), + transactionType = transactionType, + ) + onAction(SendScreenAction.Broadcast(txDataBundle)) + } setShowDialog(false) } }, diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt index 642f5d3..f70a408 100644 --- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt +++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt @@ -7,8 +7,18 @@ package org.bitcoindevkit.devkitwallet.presentation.viewmodels import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.bitcoindevkit.FeeRate import org.bitcoindevkit.Psbt +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.ERROR +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO import org.bitcoindevkit.devkitwallet.domain.Wallet private const val TAG = "SendViewModel" @@ -21,6 +31,7 @@ data class TxDataBundle( val recipients: List, val feeRate: ULong, val transactionType: TransactionType, + val wif: String? = null, ) data class Recipient(var address: String, var amount: ULong) @@ -28,9 +39,23 @@ data class Recipient(var address: String, var amount: ULong) enum class TransactionType { STANDARD, SEND_ALL, + SWEEP, +} + +sealed class BroadcastResult { + data class Success(val txid: String) : BroadcastResult() + + data class Error(val message: String) : BroadcastResult() } internal class SendViewModel(private val wallet: Wallet) : ViewModel() { + private val _broadcastResult = MutableStateFlow(null) + val broadcastResult: StateFlow = _broadcastResult.asStateFlow() + + fun clearBroadcastResult() { + _broadcastResult.value = null + } + fun onAction(action: SendScreenAction) { when (action) { is SendScreenAction.Broadcast -> broadcast(action.txDataBundle) @@ -38,31 +63,45 @@ internal class SendViewModel(private val wallet: Wallet) : ViewModel() { } private fun broadcast(txInfo: TxDataBundle) { - try { - // Create, sign, and broadcast - val psbt: Psbt = - when (txInfo.transactionType) { - TransactionType.STANDARD -> { - wallet.createTransaction( - recipientList = txInfo.recipients, - feeRate = FeeRate.fromSatPerVb(txInfo.feeRate), - ) - } + viewModelScope.launch(Dispatchers.IO) { + try { + val psbt: Psbt = + when (txInfo.transactionType) { + TransactionType.STANDARD -> { + val unsignedPsbt = wallet.createTransaction( + recipientList = txInfo.recipients, + feeRate = FeeRate.fromSatPerVb(txInfo.feeRate), + ) + wallet.sign(unsignedPsbt) + unsignedPsbt + } + + TransactionType.SWEEP -> { + val feeRate = FeeRate.fromSatPerVb(txInfo.feeRate) + val rawWif = + txInfo.wif ?: throw java.lang.IllegalStateException("WIF missing in sweep payload") + wallet.sweep(rawWif, feeRate) + } - // TransactionType.SEND_ALL -> Wallet.createSendAllTransaction(recipientList[0].address, FeeRate.fromSatPerVb(feeRate), rbfEnabled, opReturnMsg) - TransactionType.SEND_ALL -> { - throw NotImplementedError("Send all not implemented") + // TransactionType.SEND_ALL -> Wallet.createSendAllTransaction(recipientList[0].address, FeeRate.fromSatPerVb(feeRate), rbfEnabled, opReturnMsg) + TransactionType.SEND_ALL -> { + throw NotImplementedError("Send all not implemented") + } } - } - val isSigned = wallet.sign(psbt) - if (isSigned) { + val txid: String = wallet.broadcast(psbt) Log.i(TAG, "Transaction was broadcast! txid: $txid") - } else { - Log.i(TAG, "Transaction not signed.") + DwLogger.log(INFO, "Broadcast success: txid=$txid") + withContext(Dispatchers.Main) { + _broadcastResult.value = BroadcastResult.Success(txid) + } + } catch (e: Throwable) { + Log.e(TAG, "Broadcast error: ${e.message}", e) + DwLogger.log(ERROR, "Broadcast failed: ${e.message}") + withContext(Dispatchers.Main) { + _broadcastResult.value = BroadcastResult.Error(e.message ?: "Unknown error") + } } - } catch (e: Throwable) { - Log.i(TAG, "Broadcast error: ${e.message}") } } } diff --git a/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt b/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt new file mode 100644 index 0000000..13ada21 --- /dev/null +++ b/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt @@ -0,0 +1,41 @@ +package org.bitcoindevkit.devkitwallet + +import org.bitcoindevkit.Descriptor +import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.domain.utils.WifParser +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.ext.junit.runners.AndroidJUnit4 + +@RunWith(AndroidJUnit4::class) +class WifSweepInstrumentedTest { + + @Test + fun bdkDescriptorRejectsInvalidChecksumWif() { + val fakeWif = "c" + "1".repeat(51) + assertTrue("Fake WIF should pass the heuristic", WifParser.isLikelyWif(fakeWif)) + + try { + Descriptor("wpkh($fakeWif)", Network.TESTNET) + fail("BDK should have thrown on an invalid-checksum WIF") + } catch (e: Exception) { + // Expected — BDK correctly rejected the key + assertNotNull("Exception message should not be null", e.message) + } + } + + @Test + fun bdkBuildsDescriptorsFromRealWif() { + val wif = "cS7h4T8nZ2wDBWk271QdvPeAnvGUoP1tXQKGMUYKkkFq1Tk6aKoP" + assertTrue(WifParser.isLikelyWif(wif)) + + val types = listOf("pkh($wif)", "wpkh($wif)", "sh(wpkh($wif))", "tr($wif)") + for (descStr in types) { + val descriptor = Descriptor(descStr, Network.TESTNET) + assertNotNull("Descriptor for $descStr should not be null", descriptor) + } + } +} diff --git a/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt new file mode 100644 index 0000000..36adb3a --- /dev/null +++ b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt @@ -0,0 +1,176 @@ +package org.bitcoindevkit.devkitwallet + +import org.bitcoindevkit.devkitwallet.domain.utils.WifParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WifParserTest { + // ─── isLikelyWif ──────────────────────────────────────────────────────── + + @Test + fun `isLikelyWif accepts valid-looking testnet compressed WIF`() { + // 'c' prefix + 51 more base-58 chars = 52 total + val wif = "c" + "1".repeat(51) + assertTrue(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif accepts valid-looking mainnet compressed WIF starting with K`() { + val wif = "K" + "1".repeat(51) + assertTrue(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif accepts valid-looking mainnet compressed WIF starting with L`() { + val wif = "L" + "1".repeat(51) + assertTrue(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif accepts valid-looking mainnet uncompressed WIF starting with 5`() { + // '5' prefix + 50 more base-58 chars = 51 total + val wif = "5" + "1".repeat(50) + assertTrue(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif accepts valid-looking testnet uncompressed WIF starting with 9`() { + val wif = "9" + "1".repeat(50) + assertTrue(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif rejects string that is too short`() { + assertFalse(WifParser.isLikelyWif("c" + "1".repeat(10))) + } + + @Test + fun `isLikelyWif rejects string that is too long`() { + assertFalse(WifParser.isLikelyWif("c" + "1".repeat(55))) + } + + @Test + fun `isLikelyWif rejects string with wrong first character`() { + // 'A' is not a valid WIF first character + assertFalse(WifParser.isLikelyWif("A" + "1".repeat(51))) + } + + @Test + fun `isLikelyWif rejects string with non-base58 characters`() { + // '0' is not in the base-58 alphabet + val wif = "c" + "0".repeat(51) + assertFalse(WifParser.isLikelyWif(wif)) + } + + @Test + fun `isLikelyWif rejects a regular Bitcoin address`() { + // A bech32 address is clearly not a WIF + assertFalse(WifParser.isLikelyWif("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + } + + // ─── extract: raw input ────────────────────────────────────────────────── + + @Test + fun `extract returns raw WIF when input is already a valid WIF`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract(wif)) + } + + @Test + fun `extract handles leading and trailing whitespace`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract(" $wif ")) + } + + // ─── extract: wif: prefix ──────────────────────────────────────────────── + + @Test + fun `extract strips wif colon prefix`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("wif:$wif")) + } + + @Test + fun `extract strips WIF colon prefix case-insensitively`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("WIF:$wif")) + } + + // ─── extract: bitcoin URI ──────────────────────────────────────────────── + + @Test + fun `extract reads wif query parameter from bitcoin URI`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("bitcoin:?wif=$wif")) + } + + @Test + fun `extract reads privkey query parameter from bitcoin URI`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("bitcoin:?privkey=$wif")) + } + + @Test + fun `extract reads private_key query parameter from bitcoin URI`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("bitcoin:?private_key=$wif")) + } + + @Test + fun `extract reads privatekey query parameter from bitcoin URI`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("bitcoin:?privatekey=$wif")) + } + + @Test + fun `extract works when WIF param is not the first query parameter`() { + val wif = "c" + "1".repeat(51) + assertEquals(wif, WifParser.extract("bitcoin:tb1qsomeaddress?amount=0.001&wif=$wif")) + } + + // ─── extract: negative cases ───────────────────────────────────────────── + + @Test + fun `extract returns null for a random non-WIF string`() { + assertNull(WifParser.extract("12cUi8cuUJRiFmGEu4jCAsonSS1dkVyaD7Aoo6URRiXpmaokikuyM778786")) + } + + @Test + fun `extract returns null for a bech32 Bitcoin address`() { + assertNull(WifParser.extract("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + } + + @Test + fun `extract returns null for an empty string`() { + assertNull(WifParser.extract("")) + } + + @Test + fun `extract returns null for a bitcoin URI without a WIF param`() { + assertNull(WifParser.extract("bitcoin:tb1qsomeaddress?amount=0.001")) + } + + @Test + fun `extract returns null for a bitcoin URI with unrecognised param key`() { + val wif = "c" + "1".repeat(51) + // "key" is not a recognised param name + assertNull(WifParser.extract("bitcoin:?key=$wif")) + } + + // ─── extract: real WIF across all formats ─────────────────────────────── + + @Test + fun `extract handles real testnet WIF in all supported formats`() { + val wif = "cUkUX6eBYEiXULiJiDz5Cgvm5DQAZsMEw3mC6qd275kW6dk9hY8y" + assertTrue("Real WIF should pass isLikelyWif", WifParser.isLikelyWif(wif)) + assertEquals(wif, WifParser.extract(wif)) + assertEquals(wif, WifParser.extract("wif:$wif")) + assertEquals(wif, WifParser.extract("WIF:$wif")) + assertEquals(wif, WifParser.extract("bitcoin:?wif=$wif")) + assertEquals(wif, WifParser.extract("bitcoin:?privkey=$wif")) + assertEquals(wif, WifParser.extract(" $wif\n")) + } +} diff --git a/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt new file mode 100644 index 0000000..ff9e30c --- /dev/null +++ b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt @@ -0,0 +1,45 @@ +package org.bitcoindevkit.devkitwallet + +import org.bitcoindevkit.devkitwallet.domain.utils.WifParser +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WifSweepTest { + @Test + fun wifParserRejectsShortPlaceholderKey() { + val placeholder = "cTjZ2BfARk264q9s3uH8Yk3fKqK3wT95JzFp8s1tXvR9k" // 45 chars + assertFalse( + "A 45-char string must NOT pass isLikelyWif (WIF must be 51 or 52 chars)", + WifParser.isLikelyWif(placeholder), + ) + } + + @Test + fun wifParserAcceptsCorrectLengthTestnetCompressedKey() { + val wif = "c" + "1".repeat(51) + assertTrue( + "A 52-char base58 string starting with 'c' should pass the heuristic", + WifParser.isLikelyWif(wif), + ) + } + + @Test + fun extractReturnsNullForBitcoinAddress() { + assertNull( + "A bech32 address must not be mistaken for a WIF", + WifParser.extract("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"), + ) + } + + @Test + fun extractReturnsCandidateFromBitcoinUri() { + val wif = "c" + "1".repeat(51) + val result = WifParser.extract("bitcoin:?wif=$wif") + assertTrue( + "WIF extracted from a bitcoin URI should pass isLikelyWif", + result != null && WifParser.isLikelyWif(result), + ) + } +}