From c32a5b6bba9ae37b80ad8af30cd59bac647babed Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 10 May 2024 01:08:53 +0930 Subject: [PATCH] Handle QR errors --- .../securesms/dms/NewMessageFragment.kt | 10 ++-- .../securesms/dms/NewMessageViewModel.kt | 16 ++++-- .../onboarding/LinkDeviceActivity.kt | 24 +-------- .../onboarding/LinkDeviceViewModel.kt | 7 ++- .../securesms/preferences/QRCodeActivity.kt | 41 +++++++++------- .../securesms/ui/components/QR.kt | 49 ++++++++++++++++--- .../securesms/ui/components/QrImage.kt | 9 ++-- app/src/main/res/values/strings.xml | 1 + 8 files changed, 99 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index f16147bf19..2907871e20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -31,6 +31,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import network.loki.messenger.R @@ -78,6 +80,7 @@ class NewMessageFragment : Fragment() { val uiState by viewModel.state.collectAsState(State()) NewMessage( uiState, + viewModel.qrErrors, viewModel, onClose = { delegate.onDialogClosePressed() }, onBack = { delegate.onDialogBackPressed() }, @@ -104,7 +107,7 @@ private fun PreviewNewMessage( @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int ) { PreviewTheme(themeResId) { - NewMessage(State(), object: Callbacks {}) + NewMessage(State()) } } @@ -114,7 +117,8 @@ private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan) @Composable private fun NewMessage( state: State, - callbacks: Callbacks, + errors: Flow = emptyFlow(), + callbacks: Callbacks = object: Callbacks {}, onClose: () -> Unit = {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, @@ -127,7 +131,7 @@ private fun NewMessage( HorizontalPager(pagerState) { when (TITLES[it]) { R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) - R.string.qrScan -> MaybeScanQrCode(onScan = callbacks::onScan) + R.string.qrScan -> MaybeScanQrCode(errors, onScan = callbacks::onScan) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt index debdb056dd..93b4e27556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow @@ -23,26 +24,33 @@ class NewMessageViewModel @Inject constructor( private val application: Application ): AndroidViewModel(application), Callbacks { - private val _state = MutableStateFlow( - State() - ) + + private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() private val _event = Channel() val event = _event.receiveAsFlow() + private val _qrErrors = Channel() + val qrErrors: Flow = _qrErrors.receiveAsFlow() + override fun onChange(value: String) { _state.update { it.copy( newMessageIdOrOns = value, error = null ) } } + override fun onContinue() { createPrivateChatIfPossible(state.value.newMessageIdOrOns) } override fun onScan(value: String) { - createPrivateChatIfPossible(value) + if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) { + onPublicKey(value) + } else { + _qrErrors.trySend(application.getString(R.string.this_qr_code_does_not_contain_an_account_id)) + } } private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 1e6b9456d7..246132b645 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -137,8 +137,7 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on text = state.recoveryPhrase, modifier = Modifier .fillMaxWidth() - .contentDescription(R.string.AccessibilityId_recovery_phrase_input) - .padding(horizontal = 64.dp), + .contentDescription(R.string.AccessibilityId_recovery_phrase_input), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, @@ -165,24 +164,3 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on } } -class Analyzer( - private val scanner: BarcodeScanner, - private val onBarcodeScanned: (String) -> Unit -): Analyzer { - @SuppressLint("UnsafeOptInUsageError") - override fun analyze(image: ImageProxy) { - InputImage.fromMediaImage( - image.image!!, - image.imageInfo.rotationDegrees - ).let(scanner::process).apply { - addOnSuccessListener { barcodes -> - barcodes.filter { it.valueType == Barcode.TYPE_TEXT }.forEach { - it.rawValue?.let(onBarcodeScanned) - } - } - addOnCompleteListener { - image.close() - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt index 69368c41da..b855e9109b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,9 +40,12 @@ class LinkDeviceViewModel @Inject constructor( private val event = Channel() val eventFlow = event.receiveAsFlow().take(1) + private val qrErrors = Channel() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) val qrErrorsFlow = qrErrors.receiveAsFlow() - .debounce(QR_ERROR_TIME) +// .debounce(QR_ERROR_TIME) .takeWhile { event.isEmpty } .mapNotNull { application.getString(R.string.qrNotRecoveryPassword) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 2660190f63..f74673b209 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle -import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences @@ -34,33 +36,36 @@ private val TITLES = listOf(R.string.view, R.string.scan) class QRCodeActivity : PassphraseRequiredActionBarActivity() { + private val errors = Channel() + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title) setComposeContent { - Tabs(TextSecurePreferences.getLocalNumber(this)!!, onScan = ::handleQRCodeScanned) + Tabs(TextSecurePreferences.getLocalNumber(this)!!, errors.receiveAsFlow(), onScan = ::onScan) } } - fun handleQRCodeScanned(string: String) { + fun onScan(string: String) { if (!PublicKeyValidation.isValid(string)) { - return Toast.makeText(this, R.string.invalid_session_id, Toast.LENGTH_SHORT).show() - } - val recipient = Recipient.from(this, Address.fromSerialized(string), false) - start { - putExtra(ConversationActivityV2.ADDRESS, recipient.address) - setDataAndType(intent.data, intent.type) - val existingThread = threadDatabase().getThreadIdIfExistsFor(recipient) - putExtra(ConversationActivityV2.THREAD_ID, existingThread) + errors.trySend(getString(R.string.this_qr_code_does_not_contain_an_account_id)) + } else if (!isFinishing) { + val recipient = Recipient.from(this, Address.fromSerialized(string), false) + start { + putExtra(ConversationActivityV2.ADDRESS, recipient.address) + setDataAndType(intent.data, intent.type) + val existingThread = threadDatabase().getThreadIdIfExistsFor(recipient) + putExtra(ConversationActivityV2.THREAD_ID, existingThread) + } + finish() } - finish() } } @OptIn(ExperimentalFoundationApi::class) @Composable -fun Tabs(sessionId: String, onScan: (String) -> Unit) { +private fun Tabs(sessionId: String, errors: Flow, onScan: (String) -> Unit) { val pagerState = rememberPagerState { TITLES.size } Column { @@ -71,7 +76,7 @@ fun Tabs(sessionId: String, onScan: (String) -> Unit) { ) { page -> when (TITLES[page]) { R.string.view -> QrPage(sessionId) - R.string.scan -> MaybeScanQrCode(onScan = onScan) + R.string.scan -> MaybeScanQrCode(errors, onScan = onScan) } } } @@ -79,9 +84,11 @@ fun Tabs(sessionId: String, onScan: (String) -> Unit) { @Composable fun QrPage(string: String) { - Column(modifier = Modifier - .padding(horizontal = 32.dp) - .fillMaxSize()) { + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .fillMaxSize() + ) { QrImage( string = string, contentDescription = "Your session id", diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index db248aee8d..ad3c2055e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.provider.Settings import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.background @@ -47,12 +48,18 @@ import com.google.mlkit.vision.barcode.BarcodeScanner 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 kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter import network.loki.messenger.R import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.onboarding.Analyzer import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.seconds typealias CameraPreview = androidx.camera.core.Preview @@ -61,7 +68,7 @@ private const val TAG = "NewMessageFragment" @OptIn(ExperimentalPermissionsApi::class) @Composable fun MaybeScanQrCode( - errors: Flow = emptyFlow(), + errors: Flow, onClickSettings: () -> Unit = LocalContext.current.run { { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", packageName, null) @@ -75,7 +82,10 @@ fun MaybeScanQrCode( val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) if (cameraPermissionState.status.isGranted) { - ScanQrCode(errors, onScan) + ScanQrCode(errors) { + Log.d("QR", "scan: $it") + onScan(it) + } } else if (cameraPermissionState.status.shouldShowRationale) { Column( modifier = Modifier @@ -105,6 +115,7 @@ fun MaybeScanQrCode( } } +@OptIn(FlowPreview::class) @Composable fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { val localContext = LocalContext.current @@ -140,9 +151,11 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { val scaffoldState = rememberScaffoldState() LaunchedEffect(Unit) { - errors.collect { error -> - scaffoldState.snackbarHostState.showSnackbar(message = error) - } + errors.filter { scaffoldState.snackbarHostState.currentSnackbarData == null } + .buffer(0, BufferOverflow.DROP_OLDEST) + .collect { error -> + scaffoldState.snackbarHostState.showSnackbar(message = error) + } } Scaffold( @@ -185,4 +198,26 @@ private fun buildAnalysisUseCase( .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build().apply { setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) - } \ No newline at end of file + } + +class Analyzer( + private val scanner: BarcodeScanner, + private val onBarcodeScanned: (String) -> Unit +): ImageAnalysis.Analyzer { + @SuppressLint("UnsafeOptInUsageError") + override fun analyze(image: ImageProxy) { + InputImage.fromMediaImage( + image.image!!, + image.imageInfo.rotationDegrees + ).let(scanner::process).apply { + addOnSuccessListener { barcodes -> + barcodes.forEach { + it.rawValue?.let(onBarcodeScanned) + } + } + addOnCompleteListener { + image.close() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt index 756c146292..3dfe7fa028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt @@ -45,9 +45,12 @@ fun QrImage( val scope = rememberCoroutineScope() LaunchedEffect(string) { scope.launch(Dispatchers.IO) { - bitmap = QRCodeUtilities.encode(string, 400).also { - for (y in 150 until 250) { - for (x in 150 until 250) { + val c = 150 + val w = c * 2 + bitmap = QRCodeUtilities.encode(string, w).also { + val hw = 30 + for (y in c - hw until c + hw) { + for (x in c - hw until c + hw) { it.setPixel(x, y, 0x00000000) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34c90086cc..9d757a2404 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1132,4 +1132,5 @@ We couldn\'t recognize this ONS. Please check it and try again. This is your Account ID. Other users can scan it to start a conversation with you. Hey, I\'ve been using Session to chat with complete privacy and security. Come join me! My Account ID is \n\n%1$s\n\nDownload it at https://getsession.org/ + This QR code does not contain an Account ID.