Skip to content
Draft
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
152 changes: 123 additions & 29 deletions app/src/main/java/one/mixin/android/ui/qr/QRCodeProcessor.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package one.mixin.android.ui.qr

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
Expand All @@ -12,6 +16,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import one.mixin.android.extension.closeSilently
import one.mixin.android.extension.decodeQR
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

class QRCodeProcessor {
private val scanner: BarcodeScanner =
Expand All @@ -27,49 +34,136 @@ class QRCodeProcessor {
onSuccess: (String) -> Unit,
onFailure: (Exception?) -> Unit,
onComplete: (() -> Unit)? = null,
) = detectAll(
coroutineScope,
bitmap,
onSuccess = { results -> onSuccess(results.first()) },
onFailure = onFailure,
onComplete = onComplete,
)

fun detectAll(
coroutineScope: CoroutineScope,
bitmap: Bitmap,
onSuccess: (List<String>) -> Unit,
onFailure: (Exception?) -> Unit,
onComplete: (() -> Unit)? = null,
) = coroutineScope.launch {
var failure: Exception? = null
val createdBitmaps = mutableListOf<Bitmap>()
val sourceBitmap =
if (bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false).also { createdBitmaps.add(it) }
} else {
bitmap
}
try {
var url: String? = null
val inputImage = InputImage.fromBitmap(bitmap, 0)
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
url = barcodes.firstOrNull()?.rawValue
url?.let { onSuccess(it) }
val candidates =
withContext(Dispatchers.IO) {
val inverted = invert(sourceBitmap).also { createdBitmaps.add(it) }
val monochrome = monochrome(inverted, 90).also { createdBitmaps.add(it) }
listOf(sourceBitmap, inverted, monochrome)
}
.addOnFailureListener {
onFailure(it)
}
.addOnCompleteListener {
if (url == null) {
decodeWithZxing(coroutineScope, bitmap, onSuccess, onFailure, onComplete)
} else {
for (candidate in candidates) {
try {
val results = detectWithMlKit(candidate)
if (results.isNotEmpty()) {
onSuccess(results)
onComplete?.invoke()
return@launch
}
} catch (e: Exception) {
failure = e
}
}
val url =
withContext(Dispatchers.IO) {
sourceBitmap.decodeQR()
}
if (url != null) {
onSuccess(listOf(url))
onComplete?.invoke()
} else {
onComplete?.invoke()
onFailure(failure)
}
} catch (e: Exception) {
decodeWithZxing(coroutineScope, bitmap, onSuccess, onFailure, onComplete)
val url =
withContext(Dispatchers.IO) {
sourceBitmap.decodeQR()
}
if (url != null) {
onSuccess(listOf(url))
onComplete?.invoke()
} else {
onComplete?.invoke()
onFailure(e)
}
} finally {
createdBitmaps.forEach { candidate ->
if (candidate !== bitmap && !candidate.isRecycled) {
candidate.recycle()
}
}
}
}

private fun decodeWithZxing(
coroutineScope: CoroutineScope,
private suspend fun detectWithMlKit(bitmap: Bitmap): List<String> =
suspendCoroutine { continuation ->
val inputImage = InputImage.fromBitmap(bitmap, 0)
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
continuation.resume(
barcodes.mapNotNull { barcode ->
(barcode.rawValue ?: barcode.displayValue)?.takeIf { it.isNotBlank() }
},
)
}
.addOnFailureListener { continuation.resumeWithException(it) }
}

private fun invert(bitmap: Bitmap): Bitmap {
val newBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val paint = Paint()
val matrixGrayscale = ColorMatrix().apply { setSaturation(0f) }
val matrixInvert =
ColorMatrix(
floatArrayOf(
-1.0f, 0.0f, 0.0f, 0.0f, 255.0f,
0.0f, -1.0f, 0.0f, 0.0f, 255.0f,
0.0f, 0.0f, -1.0f, 0.0f, 255.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
),
)
matrixInvert.preConcat(matrixGrayscale)
paint.colorFilter = ColorMatrixColorFilter(matrixInvert)
Canvas(newBitmap).drawBitmap(bitmap, 0f, 0f, paint)
return newBitmap
}

private fun monochrome(
bitmap: Bitmap,
onSuccess: (String) -> Unit,
onFailure: (Exception?) -> Unit,
onComplete: (() -> Unit)? = null,
) = coroutineScope.launch {
val url =
withContext(Dispatchers.IO) {
bitmap.decodeQR()
threshold: Int,
): Bitmap {
val newBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val paint =
Paint().apply {
colorFilter = ColorMatrixColorFilter(createThresholdMatrix(threshold))
}
onComplete?.invoke()
if (url != null) {
onSuccess(url)
} else {
onFailure(null)
}
Canvas(newBitmap).drawBitmap(bitmap, 0f, 0f, paint)
return newBitmap
}

private fun createThresholdMatrix(threshold: Int) =
ColorMatrix(
floatArrayOf(
85f, 85f, 85f, 0f, -255f * threshold,
85f, 85f, 85f, 0f, -255f * threshold,
85f, 85f, 85f, 0f, -255f * threshold,
0f, 0f, 0f, 1f, 0f,
),
)

fun close() {
scanner.closeSilently()
}
Expand Down
113 changes: 73 additions & 40 deletions app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ import one.mixin.android.ui.web.WebActivity
import one.mixin.android.util.mlkit.scan.BaseCameraScanFragment
import one.mixin.android.util.mlkit.scan.analyze.AnalyzeResult
import one.mixin.android.util.mlkit.scan.analyze.Analyzer
import one.mixin.android.util.mlkit.scan.analyze.BarcodeScanItem
import one.mixin.android.util.mlkit.scan.analyze.BarcodeResult
import one.mixin.android.util.mlkit.scan.analyze.BarcodeScanningAnalyzer
import one.mixin.android.util.mlkit.scan.analyze.ScanCoordinateMapper
import one.mixin.android.util.mlkit.scan.analyze.ScanDecision
import one.mixin.android.util.mlkit.scan.analyze.ScanResultSelector
import one.mixin.android.util.mlkit.scan.camera.config.AspectRatioCameraConfig
import one.mixin.android.util.mlkit.scan.utils.PointUtils
import one.mixin.android.util.viewBinding
import one.mixin.android.widget.ViewfinderView
import timber.log.Timber

@AndroidEntryPoint
class ScanFragment : BaseCameraScanFragment<BarcodeResult>() {
Expand All @@ -61,6 +63,7 @@ class ScanFragment : BaseCameraScanFragment<BarcodeResult>() {
private val forScanResult by lazy { requireArguments().getBoolean(ARGS_FOR_SCAN_RESULT) }
private val fromShortcut by lazy { requireArguments().getBoolean(ARGS_SHORTCUT) }
private lateinit var getMediaResult: ActivityResultLauncher<PickVisualMediaRequest>
private val scanResultSelector = ScanResultSelector()

override fun getLayoutId() = R.layout.fragment_scan

Expand All @@ -81,6 +84,7 @@ class ScanFragment : BaseCameraScanFragment<BarcodeResult>() {
.setImageBitmap(null)
binding.viewfinderView
.showScanner()
scanResultSelector.reset()
cameraScan.setAnalyzeImage(true)
return
} else {
Expand Down Expand Up @@ -126,49 +130,78 @@ class ScanFragment : BaseCameraScanFragment<BarcodeResult>() {
super.initCameraScan()
cameraScan.setPlayBeep(false)
.setCameraConfig(AspectRatioCameraConfig(requireContext()))
.setVibrate(true)
.setVibrate(false)
}

override fun onScanResultCallback(result: AnalyzeResult<BarcodeResult>) {
val width = result.bitmap?.width ?: return
val height = result.bitmap?.height ?: return
if (result.result?.content != null) {
cameraScan.setAnalyzeImage(false)
handleAnalysis(result.result?.content!!)
} else if (result.result?.barcodes != null) {
cameraScan.setAnalyzeImage(false)
Timber.e("$width - $height ${binding.viewfinderView.width} ${binding.viewfinderView.height}")
result.result?.barcodes?.let { results ->
binding.ivResult.setImageBitmap(previewView.bitmap)
val points = mutableListOf<Point>()
for (barcode in results) {
barcode.boundingBox?.let { box ->
val point =
PointUtils.transform(
box.centerX(),
box.centerY(),
width,
height,
binding.viewfinderView.width,
binding.viewfinderView.height,
)
points.add(point)
}
}
Timber.e("$width - $height $points")
binding.viewfinderView.showResultPoints(points)
binding.viewfinderView.setOnItemClickListener(
object : ViewfinderView.OnItemClickListener {
override fun onItemClick(position: Int) {
handleAnalysis(results[position].displayValue!!)
}
},
)
if (points.size == 1) {
handleAnalysis(results[0].displayValue!!)
}
val items = result.result?.items.orEmpty()
when (val decision = scanResultSelector.select(items)) {
ScanDecision.Continue -> binding.viewfinderView.clearTrackedBounds()
is ScanDecision.Track -> trackBarcode(decision.item)
is ScanDecision.AutoHandle -> {
cameraScan.setAnalyzeImage(false)
binding.viewfinderView.clearTrackedBounds()
handleAnalysis(decision.text)
}
is ScanDecision.ShowChoices -> showBarcodeChoices(decision.items)
}
}

override fun onScanResultFailure() {
if (!binding.viewfinderView.isShowPoints) {
scanResultSelector.reset()
binding.viewfinderView.clearTrackedBounds()
}
}

private fun trackBarcode(item: BarcodeScanItem) {
val box = item.boundingBox
if (box == null) {
binding.viewfinderView.clearTrackedBounds()
return
}
binding.viewfinderView.trackResultBounds(
ScanCoordinateMapper.transform(
box,
item.sourceWidth,
item.sourceHeight,
binding.viewfinderView.width,
binding.viewfinderView.height,
),
)
}

private fun showBarcodeChoices(items: List<BarcodeScanItem>) {
cameraScan.setAnalyzeImage(false)
binding.viewfinderView.clearTrackedBounds()
binding.ivResult.setImageBitmap(previewView.bitmap)
val choiceItems = mutableListOf<BarcodeScanItem>()
val points = mutableListOf<Point>()
for (item in items) {
val box = item.boundingBox ?: continue
points.add(
ScanCoordinateMapper.transformCenter(
box,
item.sourceWidth,
item.sourceHeight,
binding.viewfinderView.width,
binding.viewfinderView.height,
),
)
choiceItems.add(item)
}
if (points.isEmpty()) {
handleAnalysis(items.first().text)
return
}
binding.viewfinderView.showResultPoints(points)
binding.viewfinderView.setOnItemClickListener(
object : ViewfinderView.OnItemClickListener {
override fun onItemClick(position: Int) {
handleAnalysis(choiceItems[position].text)
}
},
)
}

private fun onMediaPicked(uri: Uri?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
package one.mixin.android.util.mlkit.scan.analyze

import android.graphics.Point
import android.graphics.Rect
import com.google.mlkit.vision.barcode.common.Barcode

class BarcodeResult(val barcodes: List<Barcode>?, val content: String?)
data class BarcodeScanItem(
val text: String,
val boundingBox: Rect?,
val cornerPoints: List<Point>,
val sourceWidth: Int,
val sourceHeight: Int,
)

class BarcodeResult(
val items: List<BarcodeScanItem>,
val content: String? = null,
val barcodes: List<Barcode>? = null,
) {
constructor(barcodes: List<Barcode>?, content: String?) : this(
items = emptyList(),
content = content,
barcodes = barcodes,
)
}
Loading