diff --git a/app/src/main/java/to/bitkit/models/QrCodePayload.kt b/app/src/main/java/to/bitkit/models/QrCodePayload.kt new file mode 100644 index 0000000000..2c5cfa0729 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/QrCodePayload.kt @@ -0,0 +1,6 @@ +package to.bitkit.models + +data class QrCodePayload( + val text: String?, + val rawBytes: ByteArray?, +) diff --git a/app/src/main/java/to/bitkit/repositories/SeedQrRepo.kt b/app/src/main/java/to/bitkit/repositories/SeedQrRepo.kt new file mode 100644 index 0000000000..987ee0e25d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SeedQrRepo.kt @@ -0,0 +1,38 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.decodeCompactSeedQr +import com.synonym.bitkitcore.decodeStandardSeedQr +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.QrCodePayload +import to.bitkit.utils.AppError +import javax.inject.Inject + +class SeedQrRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + suspend fun decode(payload: QrCodePayload): Result = withContext(ioDispatcher) { + runSuspendCatching { + when { + payload.text?.matches(STANDARD_SEED_QR_PATTERN) == true -> { + decodeStandardSeedQr(payload.text) + } + + payload.rawBytes?.size == COMPACT_SEED_QR_LENGTH -> { + decodeCompactSeedQr(payload.rawBytes) + } + + else -> throw SeedQrImportError.InvalidPayload() + } + } + } +} + +sealed class SeedQrImportError : AppError() { + class InvalidPayload : SeedQrImportError() +} + +private const val COMPACT_SEED_QR_LENGTH = 16 +private val STANDARD_SEED_QR_PATTERN = Regex("[0-9]{48}") diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41d3bd79df..7b123a0aa0 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -148,12 +148,15 @@ class MainActivity : FragmentActivity() { if (isShowingMigrationLoading && !isRecoveryMode) { MigrationLoadingScreen(isVisible = true) } else if (!walletViewModel.walletExists && !isRecoveryMode) { - OnboardingNav( - startupNavController = rememberNavController(), - scope = scope, - appViewModel = appViewModel, - walletViewModel = walletViewModel, - ) + CompositionLocalProvider(LocalAppViewModel provides appViewModel) { + OnboardingNav( + startupNavController = rememberNavController(), + scope = scope, + appViewModel = appViewModel, + walletViewModel = walletViewModel, + modifier = Modifier.hazeSource(hazeState, zIndex = 0f) + ) + } } else { val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() @@ -360,10 +363,12 @@ private fun OnboardingNav( scope: CoroutineScope, appViewModel: AppViewModel, walletViewModel: WalletViewModel, + modifier: Modifier = Modifier, ) { NavHost( navController = startupNavController, startDestination = StartupRoutes.Terms, + modifier = modifier, ) { composable { TermsOfUseScreen( diff --git a/app/src/main/java/to/bitkit/ui/components/TabBar.kt b/app/src/main/java/to/bitkit/ui/components/TabBar.kt index 17b5da5710..45c58f06d9 100644 --- a/app/src/main/java/to/bitkit/ui/components/TabBar.kt +++ b/app/src/main/java/to/bitkit/ui/components/TabBar.kt @@ -194,7 +194,7 @@ fun BoxScope.TabBar( painter = painterResource(R.drawable.ic_scan), contentDescription = stringResource(R.string.wallet__recipient_scan), tint = Colors.Gray1, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(28.dp) ) } } diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index f0ac2ebe36..d5b250a2a4 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.onboarding +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -30,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,6 +45,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -59,6 +62,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.ButtonSize @@ -68,12 +73,15 @@ import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScanNavIcon +import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.shared.effects.BlockScreenshots import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.RestoreWalletEffect import to.bitkit.viewmodels.RestoreWalletUiState import to.bitkit.viewmodels.RestoreWalletViewModel @@ -90,23 +98,55 @@ fun RestoreWalletScreen( BlockScreenshots() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val app = appViewModel + var isScanningSeedQr by rememberSaveable { mutableStateOf(false) } - Content( - uiState = uiState, - checksumErrorVisible = uiState.checksumErrorVisible, - areButtonsEnabled = uiState.areButtonsEnabled, - onChangeWord = viewModel::onChangeWord, - onChangeWordFocus = viewModel::onChangeWordFocus, - onChangePassphrase = viewModel::onChangePassphrase, - onBackspaceInEmpty = viewModel::onBackspaceInEmpty, - onSelectSuggestion = viewModel::onSelectSuggestion, - onKeyboardDismiss = viewModel::onKeyboardDismiss, - onScrollComplete = viewModel::onScrollComplete, - onAdvancedClick = viewModel::onAdvancedClick, - onBack = onBackClick, - onRestore = onRestoreClick, - modifier = modifier, - ) + BackHandler(enabled = isScanningSeedQr) { + isScanningSeedQr = false + } + + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + RestoreWalletEffect.InvalidSeedQr -> app?.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.onboarding__restore_seedqr_error), + ) + + RestoreWalletEffect.SeedQrDecoded -> isScanningSeedQr = false + } + } + } + + if (isScanningSeedQr) { + QrScanningScreen( + onScanSuccess = viewModel::onSeedQrScan, + onBack = { isScanningSeedQr = false }, + isFullScreen = true, + acceptsBinaryPayload = true, + showsGradientBackground = false, + ) + } else { + Content( + uiState = uiState, + checksumErrorVisible = uiState.checksumErrorVisible, + areButtonsEnabled = uiState.areButtonsEnabled, + onChangeWord = viewModel::onChangeWord, + onChangeWordFocus = viewModel::onChangeWordFocus, + onChangePassphrase = viewModel::onChangePassphrase, + onBackspaceInEmpty = viewModel::onBackspaceInEmpty, + onSelectSuggestion = viewModel::onSelectSuggestion, + onKeyboardDismiss = viewModel::onKeyboardDismiss, + onScrollComplete = viewModel::onScrollComplete, + onAdvancedClick = viewModel::onAdvancedClick, + onScanClick = { isScanningSeedQr = true }, + onBack = onBackClick, + onRestore = onRestoreClick, + modifier = modifier, + ) + } } @Composable @@ -123,6 +163,7 @@ private fun Content( onKeyboardDismiss: () -> Unit = {}, onScrollComplete: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, + onScanClick: () -> Unit = {}, onRestore: (mnemonic: String, passphrase: String?) -> Unit = { _, _ -> }, onBack: () -> Unit = {}, ) { @@ -163,6 +204,7 @@ private fun Content( AppTopBar( titleText = null, onBackClick = onBack, + actions = { ScanNavIcon(onScanClick) }, ) }, modifier = modifier, diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt index 1969dae7a7..39e5efc7a1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt @@ -9,12 +9,15 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import to.bitkit.ext.nowMillis +import to.bitkit.models.QrCodePayload import to.bitkit.utils.AppError import to.bitkit.utils.Logger @OptIn(ExperimentalGetImage::class) class QrCodeAnalyzer( - private val onScanResult: (Result) -> Unit, + private val acceptsBinaryPayload: Boolean = false, + private val onScanResult: (Result) -> Unit, ) : ImageAnalysis.Analyzer { private var lastScannedCode: String? = null private var lastScanTime: Long = 0 @@ -36,26 +39,31 @@ class QrCodeAnalyzer( scanner.process(inputImage) .addOnCompleteListener { if (it.isSuccessful) { - it.result.let { barcodes -> - barcodes.forEach { barcode -> - barcode.rawValue?.let { qrCode -> - val currentTime = System.currentTimeMillis() - val isDifferentCode = qrCode != lastScannedCode - val isCooldownExpired = currentTime - lastScanTime > scanCooldownMs + selectQrCodePayload( + payloads = it.result.map { barcode -> + QrCodePayload( + text = barcode.rawValue, + rawBytes = barcode.rawBytes, + ) + }, + acceptsBinaryPayload = acceptsBinaryPayload, + )?.let { payload -> + val scanKey = payload.text ?: payload.rawBytes?.contentHashCode()?.toString() + val currentTime = nowMillis() + val isDifferentCode = scanKey != lastScannedCode + val isCooldownExpired = currentTime - lastScanTime > scanCooldownMs - if (isDifferentCode || isCooldownExpired) { - lastScannedCode = qrCode - lastScanTime = currentTime - onScanResult(Result.success(qrCode)) - } - image.close() - return@addOnCompleteListener - } + if (isDifferentCode || isCooldownExpired) { + lastScannedCode = scanKey + lastScanTime = currentTime + onScanResult( + Result.success(payload) + ) } } } else { val error = it.exception ?: AppError("Scan failed") - Logger.error(error.message.orEmpty(), error) + Logger.error("Failed to analyze QR code", error, context = "QrCodeAnalyzer") onScanResult(Result.failure(error)) } image.close() @@ -65,3 +73,9 @@ class QrCodeAnalyzer( } } } + +internal fun selectQrCodePayload( + payloads: List, + acceptsBinaryPayload: Boolean, +): QrCodePayload? = payloads.firstOrNull { it.text != null } + ?: payloads.firstOrNull { acceptsBinaryPayload && it.rawBytes != null } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index d7836dad5e..d3732f9aeb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -63,8 +63,8 @@ import to.bitkit.R import to.bitkit.env.Env import to.bitkit.ext.getClipboardText import to.bitkit.ext.startActivityAppSettings +import to.bitkit.models.QrCodePayload import to.bitkit.models.Toast -import to.bitkit.models.sanitizedQrLogValue import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -72,9 +72,11 @@ import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog +import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.Colors +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.viewmodels.AppViewModel import java.util.concurrent.Executors @@ -86,13 +88,17 @@ private const val TAG = "QrScanningScreen" @OptIn(ExperimentalPermissionsApi::class) @Composable fun QrScanningScreen( - onScanSuccess: (String) -> Unit, + modifier: Modifier = Modifier, + onScanSuccess: (QrCodePayload) -> Unit, onBack: (() -> Unit)? = null, isPubkyScan: Boolean = false, + isFullScreen: Boolean = false, + acceptsBinaryPayload: Boolean = false, + showsGradientBackground: Boolean = true, ) { val app = appViewModel ?: return - val (scanResult, setScanResult) = remember { mutableStateOf(null) } + val (scanResult, setScanResult) = remember { mutableStateOf(null) } // Handle scan result LaunchedEffect(scanResult) { @@ -124,22 +130,23 @@ fun QrScanningScreen( val context = LocalContext.current val previewView = remember { PreviewView(context) } val preview = remember { Preview.Builder().build() } - val analyzer = remember { - QrCodeAnalyzer { result -> - if (result.isSuccess) { - val qrCode = result.getOrThrow() - Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) - setScanResult(qrCode) - } else { - val error = requireNotNull(result.exceptionOrNull()) - Logger.error("Failed to scan QR code", error) - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), - ) - } - } + val analyzer = remember(acceptsBinaryPayload) { + QrCodeAnalyzer( + onScanResult = { result -> + if (result.isSuccess) { + setScanResult(result.getOrThrow()) + } else { + val error = requireNotNull(result.exceptionOrNull()) + Logger.error("Failed to scan QR code", error, context = TAG) + app.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + } + }, + acceptsBinaryPayload = acceptsBinaryPayload, + ) } val imageAnalysis = remember { ImageAnalysis.Builder() @@ -150,12 +157,28 @@ fun QrScanningScreen( val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), onResult = { uri -> - uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> app.toast(e) }) } + uri?.let { + processImageFromGallery( + context = context, + uri = it, + acceptsBinaryPayload = acceptsBinaryPayload, + onScanSuccess = setScanResult, + onError = { e -> app.toast(e) }, + ) + } } ) val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> app.toast(e) }) } + uri?.let { + processImageFromGallery( + context = context, + uri = it, + acceptsBinaryPayload = acceptsBinaryPayload, + onScanSuccess = setScanResult, + onError = { e -> app.toast(e) }, + ) + } } LaunchedEffect(lensFacing) { @@ -185,11 +208,18 @@ fun QrScanningScreen( } Column( - modifier = Modifier - .gradientBackground() + modifier = modifier + .then(if (showsGradientBackground) Modifier.gradientBackground() else Modifier) .navigationBarsPadding() ) { - SheetTopBar(stringResource(R.string.other__qr_scan), onBack = onBack) + if (isFullScreen) { + AppTopBar( + titleText = stringResource(R.string.other__qr_scan), + onBackClick = onBack, + ) + } else { + SheetTopBar(stringResource(R.string.other__qr_scan), onBack = onBack) + } CameraPermissionView( permissionState = cameraPermissionState, @@ -227,7 +257,7 @@ fun QrScanningScreen( private fun handlePaste( context: Context, app: AppViewModel, - setScanResult: (String?) -> Unit, + setScanResult: (QrCodePayload?) -> Unit, ): () -> Unit = { val clipboard = context.getClipboardText()?.trim() if (clipboard.isNullOrBlank()) { @@ -237,9 +267,18 @@ private fun handlePaste( description = context.getString(R.string.wallet__send_clipboard_empty_text), ) } - setScanResult(clipboard) + setScanResult( + clipboard?.let { + textQrCodePayload(it) + } + ) } +internal fun textQrCodePayload(text: String) = QrCodePayload( + text = text, + rawBytes = null, +) + @Composable private fun Content( isPubkyScan: Boolean, @@ -249,7 +288,7 @@ private fun Content( onClickGallery: () -> Unit, onPasteFromClipboard: () -> Unit, modifier: Modifier = Modifier, - onSubmitDebug: (String?) -> Unit, + onSubmitDebug: (QrCodePayload?) -> Unit, ) { Column( modifier = modifier @@ -356,7 +395,9 @@ private fun Content( AppAlertDialog( title = "", confirmText = stringResource(R.string.common__yes_proceed), - onConfirm = { onSubmitDebug(debugValue) }, + onConfirm = { + onSubmitDebug(textQrCodePayload(debugValue)) + }, onDismiss = { showDialog = false }, modifier = Modifier .semantics { testTagsAsResourceId = true } @@ -380,7 +421,8 @@ private fun Content( private fun processImageFromGallery( context: Context, uri: Uri, - onScanSuccess: (String) -> Unit, + acceptsBinaryPayload: Boolean, + onScanSuccess: (QrCodePayload) -> Unit, onError: (Throwable) -> Unit, ) { runCatching { @@ -392,18 +434,24 @@ private fun processImageFromGallery( scanner.process(image) .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - barcode.rawValue?.let { qrCode -> - onScanSuccess(qrCode) - Logger.info("Found QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) - return@addOnSuccessListener - } + selectQrCodePayload( + payloads = barcodes.map { + QrCodePayload( + text = it.rawValue, + rawBytes = it.rawBytes, + ) + }, + acceptsBinaryPayload = acceptsBinaryPayload, + )?.let { + onScanSuccess(it) + return@addOnSuccessListener } - Logger.error("No QR code found in the image") - onError(Exception("No QR code found in the image")) + val error = AppError("No QR code found in the image") + Logger.error("Failed to find QR code in image", error, context = TAG) + onError(error) } .addOnFailureListener { e -> - Logger.error("Failed to scan QR code from gallery", e) + Logger.error("Failed to scan QR code from gallery", e, context = TAG) onError(e) } }.onFailure { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt index 8e777e34dc..dea3a04521 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt @@ -57,7 +57,6 @@ fun FundingAdvancedScreen( label = stringResource(R.string.lightning__funding_advanced__button1), icon = R.drawable.ic_scan, iconTint = Colors.Purple, - iconSize = 13.75.dp, onClick = onLnurl, ) RectangleButton( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 10b217beff..7553e50221 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -136,7 +136,7 @@ fun SendRecipientScreen( val analyzer = remember(onEvent) { QrCodeAnalyzer { result -> if (result.isSuccess) { - val qrCode = result.getOrThrow() + val qrCode = result.getOrThrow().text ?: return@QrCodeAnalyzer Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) onEvent(SendEvent.AddressContinue(qrCode)) } else { diff --git a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt index 4bf03d868a..20bcac4c96 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt @@ -38,7 +38,7 @@ private fun Content( ) { QrScanningScreen( isPubkyScan = isPubkyScan, - onScanSuccess = onScanSuccess, + onScanSuccess = { payload -> payload.text?.let(onScanSuccess) }, onBack = onBack, ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 7a842bc018..0ed710ef41 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -236,9 +236,11 @@ fun SendSheet( composableWithDefaultTransitions { QrScanningScreen( onBack = { navController.popBackStack() }, - onScanSuccess = { - navController.popBackStack() - appViewModel.onScanResult(data = it, routePubkyKeys = true) + onScanSuccess = { payload -> + payload.text?.let { + navController.popBackStack() + appViewModel.onScanResult(data = it, routePubkyKeys = true) + } }, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index b6cfd21188..73d3033134 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -10,13 +10,18 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import to.bitkit.models.QrCodePayload +import to.bitkit.repositories.SeedQrRepo import to.bitkit.services.core.Bip39Service import javax.inject.Inject @@ -26,10 +31,14 @@ private const val WORDS_MAX = 24 @HiltViewModel class RestoreWalletViewModel @Inject constructor( private val bip39Service: Bip39Service, + private val seedQrRepo: SeedQrRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(RestoreWalletUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects: SharedFlow = _effects.asSharedFlow() + /** * Word edits validate off the main thread, so they are serialized to keep an older edit from undoing a newer one. */ @@ -110,6 +119,19 @@ class RestoreWalletViewModel @Inject constructor( fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } + fun onSeedQrScan(payload: QrCodePayload) = viewModelScope.launch { + val mnemonic = seedQrRepo.decode(payload).getOrElse { + _effects.emit(RestoreWalletEffect.InvalidSeedQr) + return@launch + } + + wordEditMutex.withLock { + replaceAllWords(mnemonic.split(" ")) + recomputeValidationState() + } + _effects.emit(RestoreWalletEffect.SeedQrDecoded) + } + private fun handlePastedWords(index: Int, pastedText: String) = viewModelScope.launch { wordEditMutex.withLock { // any whitespace chars to account for different sources like password managers @@ -271,3 +293,8 @@ data class RestoreWalletUiState( val bip39Mnemonic: String get() = words.subList(0, wordCount).joinToString(" ").trim() } + +sealed interface RestoreWalletEffect { + data object InvalidSeedQr : RestoreWalletEffect + data object SeedQrDecoded : RestoreWalletEffect +} diff --git a/app/src/main/res/drawable/ic_scan.xml b/app/src/main/res/drawable/ic_scan.xml index 4c81daea44..824d6a338c 100644 --- a/app/src/main/res/drawable/ic_scan.xml +++ b/app/src/main/res/drawable/ic_scan.xml @@ -1,22 +1,26 @@ - + + - - - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ac0ae8f9f..bce66ad0cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -477,6 +477,7 @@ Passphrase* Please type in your recovery phrase from any (paper) backup. If a word is shown in <accent>red</accent>, it means that it was not found in the recovery phrase dictionary. Check for spelling errors. + This QR code does not contain a valid 12-word SeedQR. Wallet <accent>restored</accent> You have successfully restored your wallet from backup. Enjoy Bitkit! SUGGESTIONS diff --git a/app/src/test/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzerTest.kt b/app/src/test/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzerTest.kt new file mode 100644 index 0000000000..5ddca46a9e --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzerTest.kt @@ -0,0 +1,55 @@ +package to.bitkit.ui.screens.scanner + +import org.junit.Test +import to.bitkit.models.QrCodePayload +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class QrCodeAnalyzerTest { + @Test + fun `text scanner skips preceding binary payload`() { + val selected = selectQrCodePayload( + payloads = listOf( + QrCodePayload(text = null, rawBytes = byteArrayOf(1)), + QrCodePayload(text = "lightning:invoice", rawBytes = byteArrayOf(2)), + ), + acceptsBinaryPayload = false, + ) + + assertEquals("lightning:invoice", selected?.text) + } + + @Test + fun `binary scanner prefers text payload`() { + val selected = selectQrCodePayload( + payloads = listOf( + QrCodePayload(text = null, rawBytes = byteArrayOf(1)), + QrCodePayload(text = "standard seedqr", rawBytes = byteArrayOf(2)), + ), + acceptsBinaryPayload = true, + ) + + assertEquals("standard seedqr", selected?.text) + } + + @Test + fun `text scanner rejects binary-only payload`() { + val selected = selectQrCodePayload( + payloads = listOf(QrCodePayload(text = null, rawBytes = byteArrayOf(1))), + acceptsBinaryPayload = false, + ) + + assertNull(selected) + } + + @Test + fun `binary scanner accepts binary-only payload`() { + val selected = selectQrCodePayload( + payloads = listOf(QrCodePayload(text = null, rawBytes = byteArrayOf(1, 2))), + acceptsBinaryPayload = true, + ) + + assertContentEquals(byteArrayOf(1, 2), selected?.rawBytes) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/scanner/QrScanningScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/scanner/QrScanningScreenTest.kt new file mode 100644 index 0000000000..0a18d585fa --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/scanner/QrScanningScreenTest.kt @@ -0,0 +1,17 @@ +package to.bitkit.ui.screens.scanner + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class QrScanningScreenTest { + @Test + fun `text payload does not include binary fallback`() { + val text = "1234567890123456" + + val payload = textQrCodePayload(text) + + assertEquals(text, payload.text) + assertNull(payload.rawBytes) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletSeedQrViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletSeedQrViewModelTest.kt new file mode 100644 index 0000000000..9e276e7436 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletSeedQrViewModelTest.kt @@ -0,0 +1,62 @@ +package to.bitkit.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.models.QrCodePayload +import to.bitkit.repositories.SeedQrRepo +import to.bitkit.services.core.Bip39Service +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RestoreWalletSeedQrViewModelTest : BaseUnitTest() { + private val bip39Service = mock() + private val seedQrRepo = mock() + + private lateinit var viewModel: RestoreWalletViewModel + + @Before + fun setup() { + whenever { bip39Service.isValidWord(any()) }.thenReturn(true) + whenever(bip39Service.isValidMnemonicSize(any())).thenReturn(true) + whenever { bip39Service.validateMnemonic(any()) }.thenReturn(Result.success(Unit)) + viewModel = RestoreWalletViewModel(bip39Service, seedQrRepo) + } + + @Test + fun `seedqr scan should replace words with decoded mnemonic`() = test { + val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val payload = QrCodePayload(text = "seedqr", rawBytes = null) + whenever(seedQrRepo.decode(payload)).thenReturn(Result.success(mnemonic)) + + viewModel.onSeedQrScan(payload) + advanceUntilIdle() + + assertEquals(mnemonic.split(" "), viewModel.uiState.value.words.take(12)) + assertFalse(viewModel.uiState.value.is24Words) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `invalid seedqr scan should keep words and emit error`() = test { + val payload = QrCodePayload(text = "invalid", rawBytes = null) + whenever(seedQrRepo.decode(payload)).thenReturn(Result.failure(IllegalArgumentException())) + val effect = async { viewModel.effects.first() } + + viewModel.onSeedQrScan(payload) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.words.all { it.isEmpty() }) + assertIs(effect.await()) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index d145fb6087..fc4b8ad3f3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -10,6 +10,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.repositories.SeedQrRepo import to.bitkit.services.core.Bip39Service import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -21,6 +22,7 @@ import kotlin.test.assertTrue class RestoreWalletViewModelTest : BaseUnitTest() { private val bip39Service = mock() + private val seedQrRepo = mock() private lateinit var viewModel: RestoreWalletViewModel @@ -31,7 +33,7 @@ class RestoreWalletViewModelTest : BaseUnitTest() { whenever(bip39Service.isValidMnemonicSize(any())).thenReturn(true) whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) - viewModel = RestoreWalletViewModel(bip39Service) + viewModel = RestoreWalletViewModel(bip39Service, seedQrRepo) } // region Initial State diff --git a/changelog.d/next/1319.added.md b/changelog.d/next/1319.added.md new file mode 100644 index 0000000000..2fa2249111 --- /dev/null +++ b/changelog.d/next/1319.added.md @@ -0,0 +1 @@ +Wallet recovery now supports scanning 12-word Standard and Compact SeedQR backups. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e8719ace0..38dbf7a559 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc55" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }