Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/models/QrCodePayload.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package to.bitkit.models

data class QrCodePayload(
val text: String?,
val rawBytes: ByteArray?,
)
38 changes: 38 additions & 0 deletions app/src/main/java/to/bitkit/repositories/SeedQrRepo.kt
Original file line number Diff line number Diff line change
@@ -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<String> = 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}")
17 changes: 11 additions & 6 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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<StartupRoutes.Terms> {
TermsOfUseScreen(
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/ui/components/TabBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
Expand Down
74 changes: 58 additions & 16 deletions app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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 },
Comment thread
pwltr marked this conversation as resolved.
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
Expand All @@ -123,6 +163,7 @@ private fun Content(
onKeyboardDismiss: () -> Unit = {},
onScrollComplete: () -> Unit = {},
onAdvancedClick: () -> Unit = {},
onScanClick: () -> Unit = {},
onRestore: (mnemonic: String, passphrase: String?) -> Unit = { _, _ -> },
onBack: () -> Unit = {},
) {
Expand Down Expand Up @@ -163,6 +204,7 @@ private fun Content(
AppTopBar(
titleText = null,
onBackClick = onBack,
actions = { ScanNavIcon(onScanClick) },
)
},
modifier = modifier,
Expand Down
46 changes: 30 additions & 16 deletions app/src/main/java/to/bitkit/ui/screens/scanner/QrCodeAnalyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Unit,
private val acceptsBinaryPayload: Boolean = false,
private val onScanResult: (Result<QrCodePayload>) -> Unit,
) : ImageAnalysis.Analyzer {
private var lastScannedCode: String? = null
private var lastScanTime: Long = 0
Expand All @@ -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()
Expand All @@ -65,3 +73,9 @@ class QrCodeAnalyzer(
}
}
}

internal fun selectQrCodePayload(
payloads: List<QrCodePayload>,
acceptsBinaryPayload: Boolean,
): QrCodePayload? = payloads.firstOrNull { it.text != null }
?: payloads.firstOrNull { acceptsBinaryPayload && it.rawBytes != null }
Loading
Loading