Replaced MLKit with ZXing for QR code scanning (#1630)

* Replaced MLKit with ZXing for QR code scanning

* Adjusted some comment spacing

* Adjusted some comment phrasing

* Renamed MaybeScanQrCode to QRScannerScreen & removed double-import of ZXing core + removed ZXing android-integration

---------

Co-authored-by: alansley <aclansley@gmail.com>
This commit is contained in:
AL-Session 2024-08-21 12:10:55 +10:00 committed by GitHub
parent 70f217c030
commit ab2e3744a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 65 additions and 44 deletions

View File

@ -274,8 +274,6 @@ dependencies {
implementation 'pl.tajchert:waitingdots:0.1.0' implementation 'pl.tajchert:waitingdots:0.1.0'
implementation 'com.vanniktech:android-image-cropper:4.5.0' implementation 'com.vanniktech:android-image-cropper:4.5.0'
implementation 'com.melnykov:floatingactionbutton:1.3.0' implementation 'com.melnykov:floatingactionbutton:1.3.0'
implementation 'com.google.zxing:android-integration:3.1.0'
implementation 'com.google.zxing:core:3.2.1'
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'com.android.support', module: 'support-annotations'
} }
@ -352,7 +350,6 @@ dependencies {
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
testImplementation 'app.cash.turbine:turbine:1.1.0' testImplementation 'app.cash.turbine:turbine:1.1.0'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation "androidx.compose.ui:ui:$composeVersion" implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.animation:animation:$composeVersion" implementation "androidx.compose.animation:animation:$composeVersion"
@ -371,7 +368,8 @@ dependencies {
implementation "androidx.camera:camera-lifecycle:1.3.2" implementation "androidx.camera:camera-lifecycle:1.3.2"
implementation "androidx.camera:camera-view:1.3.2" implementation "androidx.camera:camera-view:1.3.2"
implementation "com.google.mlkit:barcode-scanning:17.2.0" // Note: ZXing 3.5.3 is the latest stable release as of 2024/08/21
implementation "com.google.zxing:core:$zxingVersion"
} }
static def getLastCommitTimestamp() { static def getLastCommitTimestamp() {

View File

@ -48,7 +48,7 @@ import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.BackAppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.components.SessionTabRow
@ -89,7 +89,7 @@ internal fun NewMessage(
HorizontalPager(pagerState) { HorizontalPager(pagerState) {
when (TITLES[it]) { when (TITLES[it]) {
R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp)
R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = callbacks::onScanQrCode) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode)
} }
} }
} }

View File

@ -26,7 +26,7 @@ import network.loki.messenger.R
import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton
import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.LocalType
@ -52,7 +52,7 @@ internal fun LoadAccountScreen(
) { page -> ) { page ->
when (TITLES[page]) { when (TITLES[page]) {
R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue)
R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = onScan) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan)
} }
} }
} }

View File

@ -6,6 +6,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -14,7 +15,6 @@ import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity
import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.start import org.thoughtcrime.securesms.util.start
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class LoadAccountActivity : BaseActionBarActivity() { class LoadAccountActivity : BaseActionBarActivity() {

View File

@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -17,7 +18,6 @@ import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InputTooShort import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InputTooShort
import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InvalidWord import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InvalidWord
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import javax.inject.Inject
class LoadAccountEvent(val mnemonic: ByteArray) class LoadAccountEvent(val mnemonic: ByteArray)
@ -54,6 +54,7 @@ internal class LoadAccountViewModel @Inject constructor(
} }
fun onScanQrCode(string: String) { fun onScanQrCode(string: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
codec.decodeMnemonicOrHexAsByteArray(string).let(::onSuccess) codec.decodeMnemonicOrHexAsByteArray(string).let(::onSuccess)

View File

@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.threadDatabase import org.thoughtcrime.securesms.database.threadDatabase
import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.contentDescription
@ -54,7 +54,7 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() {
} }
} }
fun onScan(string: String) { private fun onScan(string: String) {
if (!PublicKeyValidation.isValid(string)) { if (!PublicKeyValidation.isValid(string)) {
errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id)) errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id))
} else if (!isFinishing) { } else if (!isFinishing) {
@ -83,7 +83,7 @@ private fun Tabs(accountId: String, errors: Flow<String>, onScan: (String) -> Un
) { page -> ) { page ->
when (TITLES[page]) { when (TITLES[page]) {
R.string.view -> QrPage(accountId) R.string.view -> QrPage(accountId)
R.string.scan -> MaybeScanQrCode(errors, onScan = onScan) R.string.scan -> QRScannerScreen(errors, onScan = onScan)
} }
} }
} }

View File

@ -47,25 +47,27 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale import com.google.accompanist.permissions.shouldShowRationale
import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.zxing.BinaryBitmap
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.zxing.ChecksumException
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.zxing.FormatException
import com.google.mlkit.vision.barcode.common.Barcode import com.google.zxing.NotFoundException
import com.google.mlkit.vision.common.InputImage import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.Result
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import java.util.concurrent.Executors
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.LocalType
import java.util.concurrent.Executors
private const val TAG = "NewMessageFragment" private const val TAG = "NewMessageFragment"
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun MaybeScanQrCode( fun QRScannerScreen(
errors: Flow<String>, errors: Flow<String>,
onClickSettings: () -> Unit = LocalContext.current.run { { onClickSettings: () -> Unit = LocalContext.current.run { {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
@ -137,17 +139,13 @@ fun ScanQrCode(errors: Flow<String>, onScan: (String) -> Unit) {
runCatching { runCatching {
cameraProvider.get().unbindAll() cameraProvider.get().unbindAll()
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
val scanner = BarcodeScanning.getClient(options)
cameraProvider.get().bindToLifecycle( cameraProvider.get().bindToLifecycle(
LocalLifecycleOwner.current, LocalLifecycleOwner.current,
selector, selector,
preview, preview,
buildAnalysisUseCase(scanner, onScan) buildAnalysisUseCase(QRCodeReader(), onScan)
) )
}.onFailure { Log.e(TAG, "error binding camera", it) } }.onFailure { Log.e(TAG, "error binding camera", it) }
DisposableEffect(cameraProvider) { DisposableEffect(cameraProvider) {
@ -211,32 +209,51 @@ fun ScanQrCode(errors: Flow<String>, onScan: (String) -> Unit) {
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
private fun buildAnalysisUseCase( private fun buildAnalysisUseCase(
scanner: BarcodeScanner, scanner: QRCodeReader,
onBarcodeScanned: (String) -> Unit onBarcodeScanned: (String) -> Unit
): ImageAnalysis = ImageAnalysis.Builder() ): ImageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build().apply { .build().apply {
setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) setAnalyzer(Executors.newSingleThreadExecutor(), QRCodeAnalyzer(scanner, onBarcodeScanned))
} }
class Analyzer( class QRCodeAnalyzer(
private val scanner: BarcodeScanner, private val qrCodeReader: QRCodeReader,
private val onBarcodeScanned: (String) -> Unit private val onBarcodeScanned: (String) -> Unit
): ImageAnalysis.Analyzer { ): ImageAnalysis.Analyzer {
// Note: This analyze method is called once per frame of the camera feed.
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
InputImage.fromMediaImage( // Grab the image data as a byte array so we can generate a PlanarYUVLuminanceSource from it
image.image!!, val buffer = image.planes[0].buffer
image.imageInfo.rotationDegrees buffer.rewind()
).let(scanner::process).apply { val imageBytes = ByteArray(buffer.capacity())
addOnSuccessListener { barcodes -> buffer.get(imageBytes) // IMPORTANT: This transfers data from the buffer INTO the imageBytes array, although it looks like it would go the other way around!
barcodes.forEach {
it.rawValue?.let(onBarcodeScanned) // ZXing requires data as a BinaryBitmap to scan for QR codes, and to generate that we need to feed it a PlanarYUVLuminanceSource
val luminanceSource = PlanarYUVLuminanceSource(imageBytes, image.width, image.height, 0, 0, image.width, image.height, false)
val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource))
// Attempt to extract a QR code from the binary bitmap, and pass it through to our `onBarcodeScanned` method if we find one
try {
val result: Result = qrCodeReader.decode(binaryBitmap)
val resultTxt = result.text
// No need to close the image here - it'll always make it to the end, and calling `onBarcodeScanned`
// with a valid contact / recovery phrase / community code will stop calling this `analyze` method.
onBarcodeScanned(resultTxt)
} }
catch (nfe: NotFoundException) { /* Hits if there is no QR code in the image */ }
catch (fe: FormatException) { /* Hits if we found a QR code but failed to decode it */ }
catch (ce: ChecksumException) { /* Hits if we found a QR code which is corrupted */ }
catch (e: Exception) {
// Hits if there's a genuine problem
Log.e("QR", "error", e)
} }
addOnCompleteListener {
// Remember to close the image when we're done with it!
// IMPORTANT: It is CLOSING the image that allows this method to run again! If we don't
// close the image this method runs precisely ONCE and that's it, which is essentially useless.
image.close() image.close()
} }
}
}
} }

View File

@ -6,6 +6,7 @@ import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import org.session.libsignal.utilities.Log
object QRCodeUtilities { object QRCodeUtilities {
@ -34,5 +35,8 @@ object QRCodeUtilities {
} }
} }
} }
}.getOrNull() }.getOrElse {
Log.e("QRCodeUtilities", "Failed to generate QR Code", it)
null
}
} }

View File

@ -40,6 +40,7 @@ phraseVersion=1.2.0
preferenceVersion=1.2.0 preferenceVersion=1.2.0
protobufVersion=2.5.0 protobufVersion=2.5.0
testCoreVersion=1.5.0 testCoreVersion=1.5.0
zxingVersion=3.5.3
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false