From 699937bd175d637b016cd915e0c49999f07e363f Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:19:23 -0400 Subject: [PATCH 1/4] feat(scanner): migrate QR scanner to CameraX + ML Kit (bundled, offline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace zxing-lite (ancient CameraX 1.0.x + weaker ZXing decoder) with a modern CameraX 1.4.1 preview/ImageAnalysis pipeline feeding Google ML Kit's bundled barcode model (com.google.mlkit:barcode-scanning) — far more robust on angled/low-light/dense QR, and fully offline with no Google Play Services dependency (works on de-Googled ROMs). - Live scan: CameraX ImageAnalysis -> ML Kit (QR format only), STRATEGY_KEEP_ONLY_LATEST. - Image upload: decode bitmap -> ML Kit InputImage.fromBitmap (was ZXing CodeUtils); multi-image still supported; clear 'no QR found' message. - New ScannerOverlayView viewfinder (replaces king ViewfinderView) + Material torch FAB (hidden when no flash unit) with flash on/off icons. - Runtime CAMERA permission via ActivityResult API; torch via CameraControl. - Kept com.google.zxing:core for QR *generation* (QRCodeDialog); ML Kit can't encode. Compiles; APK +~5.7MB (bundled barcode model libbarhopper). On-device verification pending (device disconnected from ADB during testing). --- app/build.gradle.kts | 13 +- .../sagernet/ui/ScannerActivity.kt | 296 ++++++++++-------- .../sagernet/widget/ScannerOverlayView.kt | 68 ++++ .../res/drawable/ic_baseline_flash_off_24.xml | 10 + .../res/drawable/ic_baseline_flash_on_24.xml | 10 + app/src/main/res/layout/layout_scanner.xml | 43 ++- app/src/main/res/values/strings.xml | 3 + 7 files changed, 305 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt create mode 100644 app/src/main/res/drawable/ic_baseline_flash_off_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_flash_on_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 493b58b71..647d1c5b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,7 +60,18 @@ dependencies { implementation("com.google.android.material:material:1.12.0") implementation("com.google.code.gson:gson:2.11.0") - implementation("com.github.jenly1314:zxing-lite:2.1.1") + // QR scanning: CameraX live preview/analysis + ML Kit bundled barcode model. + // Bundled (not play-services-*) so detection works fully offline with no Google + // Play Services dependency — important for de-Googled ROMs. Replaces zxing-lite, + // which pulled in ancient CameraX 1.0.x and a weaker ZXing decoder. + implementation("androidx.camera:camera-core:1.4.1") + implementation("androidx.camera:camera-camera2:1.4.1") + implementation("androidx.camera:camera-lifecycle:1.4.1") + implementation("androidx.camera:camera-view:1.4.1") + implementation("com.google.mlkit:barcode-scanning:17.3.0") + // ZXing core (pure-Java) is kept only for QR *generation* (QRCodeDialog); ML Kit + // scans but cannot encode. It was previously transitive via zxing-lite. + implementation("com.google.zxing:core:3.5.3") implementation("com.blacksquircle.ui:editorkit:2.6.0") implementation("com.blacksquircle.ui:language-base:2.6.0") implementation("com.blacksquircle.ui:language-json:2.6.0") diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt index 31fc89b7d..dbf2c5529 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt @@ -2,39 +2,70 @@ package io.nekohasekai.sagernet.ui import android.Manifest import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ShortcutManager import android.graphics.ImageDecoder import android.os.Build import android.os.Bundle import android.provider.MediaStore +import android.util.Size import android.view.Menu import android.view.MenuItem import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn as AndroidXOptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri -import com.google.zxing.Result -import com.king.zxing.CameraScan -import com.king.zxing.DefaultCameraScan -import com.king.zxing.analyze.QRCodeAnalyzer -import com.king.zxing.util.CodeUtils -import com.king.zxing.util.LogUtils -import com.king.zxing.util.PermissionUtils +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 io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.databinding.LayoutScannerBinding import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +class ScannerActivity : ThemedActivity() { -class ScannerActivity : ThemedActivity(), - CameraScan.OnScanResultCallback { + private lateinit var binding: LayoutScannerBinding + private lateinit var cameraExecutor: ExecutorService + private var cameraProvider: ProcessCameraProvider? = null + private var camera: androidx.camera.core.Camera? = null + private var torchOn = false - lateinit var binding: LayoutScannerBinding - lateinit var cameraScan: CameraScan + // Bundled ML Kit barcode scanner restricted to QR for speed; works fully offline. + private val barcodeScanner: BarcodeScanner by lazy { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + } + + private val finished = AtomicBoolean(false) + private val importedN = AtomicInteger(0) + + private val requestCamera = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) startCamera() else finish() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,10 +79,93 @@ class ScannerActivity : ThemedActivity(), setHomeAsUpIndicator(R.drawable.ic_navigation_close) } - // QR code library - initCameraScan() - startCamera() - binding.ivFlashlight.setOnClickListener { toggleTorchState() } + cameraExecutor = Executors.newSingleThreadExecutor() + binding.fabTorch.setOnClickListener { toggleTorch() } + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + requestCamera.launch(Manifest.permission.CAMERA) + } + } + + private fun startCamera() { + val future = ProcessCameraProvider.getInstance(this) + future.addListener({ + val provider = try { + future.get() + } catch (e: Exception) { + Logs.w(e) + Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() + finish() + return@addListener + } + cameraProvider = provider + + val preview = Preview.Builder().build().also { + it.surfaceProvider = binding.previewView.surfaceProvider + } + + val resolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1280, 720), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + ) + .build() + + val analysis = ImageAnalysis.Builder() + .setResolutionSelector(resolutionSelector) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, ::analyze) } + + try { + provider.unbindAll() + camera = provider.bindToLifecycle( + this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis + ) + // Hide the torch button if the device has no flash unit. + binding.fabTorch.visibility = + if (camera?.cameraInfo?.hasFlashUnit() == true) android.view.View.VISIBLE + else android.view.View.GONE + } catch (e: Exception) { + Logs.w(e) + Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() + finish() + } + }, ContextCompat.getMainExecutor(this)) + } + + @AndroidXOptIn(ExperimentalGetImage::class) + private fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image + if (mediaImage == null || finished.get()) { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + barcodeScanner.process(input) + .addOnSuccessListener { barcodes -> + barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue?.let { + onText(it, multi = false) + } + } + .addOnFailureListener { Logs.w(it) } + .addOnCompleteListener { imageProxy.close() } + } + + private fun toggleTorch() { + val cam = camera ?: return + if (cam.cameraInfo.hasFlashUnit() != true) return + torchOn = !torchOn + cam.cameraControl.enableTorch(torchOn) + binding.fabTorch.setImageResource( + if (torchOn) R.drawable.ic_baseline_flash_on_24 else R.drawable.ic_baseline_flash_off_24 + ) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -59,73 +173,75 @@ class ScannerActivity : ThemedActivity(), return true } - val importCodeFile = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == R.id.action_import_file) { + importCodeFile.launch("image/*") + true + } else { + super.onOptionsItemSelected(item) + } + } + + private val importCodeFile = registerForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isEmpty()) return@registerForActivityResult runOnDefaultDispatcher { + var found = false try { - it.forEachTry { uri -> + uris.forEachTry { uri -> val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap( - ImageDecoder.createSource( - contentResolver, uri - ) + ImageDecoder.createSource(contentResolver, uri) ) { decoder, _, _ -> decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE decoder.isMutableRequired = true } } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap( - contentResolver, uri - ) + @Suppress("DEPRECATION") + MediaStore.Images.Media.getBitmap(contentResolver, uri) + } + val barcodes = com.google.android.gms.tasks.Tasks.await( + barcodeScanner.process(InputImage.fromBitmap(bitmap, 0)) + ) + val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (text != null) { + found = true + onText(text, multi = true) } - val result = CodeUtils.parseCodeResult(bitmap) + } + if (!found) { onMainDispatcher { - onScanResultCallback(result, true) + Toast.makeText(app, R.string.scan_no_qr_found, Toast.LENGTH_LONG).show() } } - finish() } catch (e: Exception) { Logs.w(e) onMainDispatcher { Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() } + } finally { + if (found) onMainDispatcher { finish() } } } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == R.id.action_import_file) { - startFilesForResult(importCodeFile, "image/*") - true - } else { - super.onOptionsItemSelected(item) - } - } - - var finished = AtomicBoolean(false) - var importedN = AtomicInteger(0) - /** - * Callback for receiving scan results - * @param result scan result - * @return returning true means intercept (subsequent logic won't run automatically); false means don't intercept; defaults to not intercepting + * Handle a decoded QR payload. For live scanning (multi = false) only the first + * result is accepted and the activity finishes; for image import (multi = true) + * every selected image can contribute profiles. */ - override fun onScanResultCallback(result: Result?): Boolean { - return onScanResultCallback(result, false) - } - - fun onScanResultCallback(result: Result?, multi: Boolean): Boolean { - if (!multi && finished.getAndSet(true)) return true + private fun onText(text: String, multi: Boolean) { + if (!multi && finished.getAndSet(true)) return if (!multi) finish() runOnDefaultDispatcher { try { - val text = result?.text ?: throw Exception("QR code not found") val results = RawUpdater.parseRaw(text) if (!results.isNullOrEmpty()) { val currentGroupId = DataStore.selectedGroupForImport() if (DataStore.selectedGroup != currentGroupId) { DataStore.selectedGroup = currentGroupId } - for (profile in results) { ProfileManager.createProfile(currentGroupId, profile) importedN.addAndGet(1) @@ -143,89 +259,21 @@ class ScannerActivity : ThemedActivity(), } catch (e: Throwable) { Logs.w(e) onMainDispatcher { - var text = getString(R.string.action_import_err) - text += "\n" + e.readableMessage - Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + val msg = getString(R.string.action_import_err) + "\n" + e.readableMessage + Toast.makeText(app, msg, Toast.LENGTH_SHORT).show() } } } - return true - } - - /** - * Initialize CameraScan - */ - fun initCameraScan() { - cameraScan = DefaultCameraScan(this, binding.previewView) - cameraScan.setAnalyzer(QRCodeAnalyzer()) - cameraScan.setOnScanResultCallback(this) - cameraScan.setNeedAutoZoom(true) - } - - /** - * Start camera preview - */ - fun startCamera() { - if (PermissionUtils.checkPermission(this, Manifest.permission.CAMERA)) { - cameraScan.startCamera() - } else { - LogUtils.d("checkPermissionResult != PERMISSION_GRANTED") - PermissionUtils.requestPermission( - this, Manifest.permission.CAMERA, CAMERA_PERMISSION_REQUEST_CODE - ) - } - } - - /** - * Release the camera - */ - private fun releaseCamera() { - cameraScan.release() - } - - /** - * Toggle flashlight state (on/off) - */ - protected fun toggleTorchState() { - val isTorch = cameraScan.isTorchEnabled - cameraScan.enableTorch(!isTorch) - binding.ivFlashlight.isSelected = !isTorch - } - - val CAMERA_PERMISSION_REQUEST_CODE = 0X86 - - override fun onRequestPermissionsResult( - requestCode: Int, permissions: Array, grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { - requestCameraPermissionResult(permissions, grantResults) - } - } - - /** - * Callback result for requesting Camera permission - * @param permissions - * @param grantResults - */ - fun requestCameraPermissionResult(permissions: Array, grantResults: IntArray) { - if (PermissionUtils.requestPermissionsResult( - Manifest.permission.CAMERA, permissions, grantResults - ) - ) { - startCamera() - } else { - finish() - } } override fun onDestroy() { - releaseCamera() super.onDestroy() + cameraProvider?.unbindAll() + if (::cameraExecutor.isInitialized) cameraExecutor.shutdown() + barcodeScanner.close() if (importedN.get() > 0) { - var text = getString(R.string.action_import_msg) - text += "\n" + importedN.get() + " profile(s)" + val text = getString(R.string.action_import_msg) + "\n" + importedN.get() + " profile(s)" Toast.makeText(app, text, Toast.LENGTH_LONG).show() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt new file mode 100644 index 000000000..ae2404551 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt @@ -0,0 +1,68 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import kotlin.math.min + +/** + * A simple QR-scanner viewfinder overlay: dims the whole surface and punches a + * rounded-square transparent "scan window" in the center with a tinted border. + * Replaces the zxing-lite ViewfinderView (removed with the ZXing dependency). + */ +class ScannerOverlayView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + + private val scrimPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0x99000000.toInt() // ~60% black scrim + } + private val clearPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.WHITE + strokeWidth = dp(3f) + } + + private val windowRect = RectF() + private var cornerRadius = dp(16f) + + /** The scan window in view coordinates, for mapping ML Kit results / cropping. */ + val scanWindow: RectF get() = RectF(windowRect) + + init { + // Needed so the CLEAR xfermode punches a hole rather than painting black. + setLayerType(LAYER_TYPE_HARDWARE, null) + } + + fun setBorderColor(color: Int) { + borderPaint.color = color + invalidate() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Square window ~70% of the shorter side, centered. + val side = min(w, h) * 0.7f + val left = (w - side) / 2f + val top = (h - side) / 2f + windowRect.set(left, top, left + side, top + side) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), scrimPaint) + canvas.drawRoundRect(windowRect, cornerRadius, cornerRadius, clearPaint) + canvas.drawRoundRect(windowRect, cornerRadius, cornerRadius, borderPaint) + } + + private fun dp(value: Float) = value * resources.displayMetrics.density +} diff --git a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml new file mode 100644 index 000000000..6696932ee --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flash_on_24.xml b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml new file mode 100644 index 000000000..b3e8784de --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/layout_scanner.xml b/app/src/main/res/layout/layout_scanner.xml index 0eb4555e6..871433e95 100644 --- a/app/src/main/res/layout/layout_scanner.xml +++ b/app/src/main/res/layout/layout_scanner.xml @@ -1,28 +1,45 @@ - - - - + + - \ No newline at end of file + android:layout_gravity="center_horizontal|top" + android:layout_marginTop="120dp" + android:background="#66000000" + android:paddingHorizontal="16dp" + android:paddingVertical="8dp" + android:text="@string/scan_qr_hint" + android:textColor="@android:color/white" + android:textAppearance="?attr/textAppearanceBody2" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1069dd9c8..acd900ab3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,9 @@ Export Import from Clipboard Import from file + Point the camera at a QR code + Toggle flashlight + No QR code found in the selected image Successfully export! Failed to export. Successfully import! From 392d90fafc1afa38bce083ffc63982fc25faca4d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:38:26 -0400 Subject: [PATCH 2/4] fix(scanner): use explicit padding attrs (paddingHorizontal/Vertical are API 26+, minSdk 21) --- app/src/main/res/layout/layout_scanner.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/layout_scanner.xml b/app/src/main/res/layout/layout_scanner.xml index 871433e95..f4e3824fc 100644 --- a/app/src/main/res/layout/layout_scanner.xml +++ b/app/src/main/res/layout/layout_scanner.xml @@ -26,8 +26,10 @@ android:layout_gravity="center_horizontal|top" android:layout_marginTop="120dp" android:background="#66000000" - android:paddingHorizontal="16dp" - android:paddingVertical="8dp" + android:paddingLeft="16dp" + android:paddingTop="8dp" + android:paddingRight="16dp" + android:paddingBottom="8dp" android:text="@string/scan_qr_hint" android:textColor="@android:color/white" android:textAppearance="?attr/textAppearanceBody2" /> From 18025567f62e89945bd2f796802457da6e3167f5 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:53:09 -0400 Subject: [PATCH 3/4] review: await import before finishing, downsample imported images, stateful torch a11y Addresses CodeRabbit findings on the scanner: - Image import now decodes bounded to MAX_IMPORT_DIMEN (2048px longer edge) via ImageDecoder target size (P+) / BitmapFactory inSampleSize (pre-P), so large gallery images can't OOM before ML Kit runs; bitmaps are recycled after scanning. - Split parsing into a suspend importText() that returns the created-profile count; the multi-image flow awaits it and only finish()es after imports complete (and only when a profile was actually created, not merely when QR text was found). Live scan latches 'finished' then imports in the background. - Torch FAB content-description is now stateful (turn on / turn off) for screen readers. --- .../sagernet/ui/ScannerActivity.kt | 156 ++++++++++++------ app/src/main/res/values/strings.xml | 2 + 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt index dbf2c5529..7d24681b3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt @@ -7,7 +7,6 @@ import android.content.pm.ShortcutManager import android.graphics.ImageDecoder import android.os.Build import android.os.Bundle -import android.provider.MediaStore import android.util.Size import android.view.Menu import android.view.MenuItem @@ -132,6 +131,7 @@ class ScannerActivity : ThemedActivity() { binding.fabTorch.visibility = if (camera?.cameraInfo?.hasFlashUnit() == true) android.view.View.VISIBLE else android.view.View.GONE + binding.fabTorch.contentDescription = getString(R.string.scan_torch_turn_on) } catch (e: Exception) { Logs.w(e) Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() @@ -150,8 +150,12 @@ class ScannerActivity : ThemedActivity() { val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) barcodeScanner.process(input) .addOnSuccessListener { barcodes -> - barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue?.let { - onText(it, multi = false) + val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (text != null && !finished.getAndSet(true)) { + // First successful scan wins: finish the activity and import in the + // background (the activity-scoped toast in onDestroy reports the count). + finish() + runOnDefaultDispatcher { importText(text) } } } .addOnFailureListener { Logs.w(it) } @@ -166,6 +170,10 @@ class ScannerActivity : ThemedActivity() { binding.fabTorch.setImageResource( if (torchOn) R.drawable.ic_baseline_flash_on_24 else R.drawable.ic_baseline_flash_off_24 ) + // Keep the accessibility label describing the action the button will perform. + binding.fabTorch.contentDescription = getString( + if (torchOn) R.string.scan_torch_turn_off else R.string.scan_torch_turn_on + ) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -187,30 +195,25 @@ class ScannerActivity : ThemedActivity() { ) { uris -> if (uris.isEmpty()) return@registerForActivityResult runOnDefaultDispatcher { - var found = false + var foundQr = false + var imported = 0 try { uris.forEachTry { uri -> - val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap( - ImageDecoder.createSource(contentResolver, uri) - ) { decoder, _, _ -> - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.isMutableRequired = true - } - } else { - @Suppress("DEPRECATION") - MediaStore.Images.Media.getBitmap(contentResolver, uri) + val bitmap = decodeBoundedBitmap(uri) + val barcodes = try { + com.google.android.gms.tasks.Tasks.await( + barcodeScanner.process(InputImage.fromBitmap(bitmap, 0)) + ) + } finally { + bitmap.recycle() } - val barcodes = com.google.android.gms.tasks.Tasks.await( - barcodeScanner.process(InputImage.fromBitmap(bitmap, 0)) - ) val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue if (text != null) { - found = true - onText(text, multi = true) + foundQr = true + imported += importText(text) } } - if (!found) { + if (!foundQr) { onMainDispatcher { Toast.makeText(app, R.string.scan_no_qr_found, Toast.LENGTH_LONG).show() } @@ -221,48 +224,91 @@ class ScannerActivity : ThemedActivity() { Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() } } finally { - if (found) onMainDispatcher { finish() } + // Only finish once the import actually completed (importText is awaited + // above) and at least one profile was created. + if (imported > 0) onMainDispatcher { finish() } } } } /** - * Handle a decoded QR payload. For live scanning (multi = false) only the first - * result is accepted and the activity finishes; for image import (multi = true) - * every selected image can contribute profiles. + * Decode a user-selected image bounded to [MAX_IMPORT_DIMEN] on its longer edge. + * Arbitrary gallery images can be huge; decoding at full resolution risks an OOM + * before any exception handler runs. ML Kit detects QR codes fine at this size. */ - private fun onText(text: String, multi: Boolean) { - if (!multi && finished.getAndSet(true)) return - if (!multi) finish() - runOnDefaultDispatcher { - try { - val results = RawUpdater.parseRaw(text) - if (!results.isNullOrEmpty()) { - val currentGroupId = DataStore.selectedGroupForImport() - if (DataStore.selectedGroup != currentGroupId) { - DataStore.selectedGroup = currentGroupId - } - for (profile in results) { - ProfileManager.createProfile(currentGroupId, profile) - importedN.addAndGet(1) - } - } else { - onMainDispatcher { - Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() - } + private fun decodeBoundedBitmap(uri: android.net.Uri): android.graphics.Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource(contentResolver, uri) + ) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + val longer = maxOf(info.size.width, info.size.height) + if (longer > MAX_IMPORT_DIMEN) { + val scale = MAX_IMPORT_DIMEN.toFloat() / longer + decoder.setTargetSize( + (info.size.width * scale).toInt().coerceAtLeast(1), + (info.size.height * scale).toInt().coerceAtLeast(1) + ) } - } catch (e: SubscriptionFoundException) { - startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = e.link.toUri() - }) - } catch (e: Throwable) { - Logs.w(e) + } + } else { + // Pre-P: read bounds first, then decode with an inSampleSize so the full-res + // bitmap is never materialized. + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + contentResolver.openInputStream(uri)?.use { + android.graphics.BitmapFactory.decodeStream(it, null, bounds) + } + var sample = 1 + val longer = maxOf(bounds.outWidth, bounds.outHeight) + while (longer / sample > MAX_IMPORT_DIMEN) sample *= 2 + val opts = android.graphics.BitmapFactory.Options().apply { inSampleSize = sample } + (contentResolver.openInputStream(uri)?.use { + android.graphics.BitmapFactory.decodeStream(it, null, opts) + }) ?: error("Cannot decode image") + } + } + + /** + * Parse a decoded QR payload and create the resulting profile(s). Suspends until the + * import completes and returns the number of profiles created (0 if none / on error), + * so callers can wait before finishing. A SubscriptionFoundException opens the + * subscription import flow instead. + */ + private suspend fun importText(text: String): Int { + return try { + val results = RawUpdater.parseRaw(text) + if (!results.isNullOrEmpty()) { + val currentGroupId = DataStore.selectedGroupForImport() + if (DataStore.selectedGroup != currentGroupId) { + DataStore.selectedGroup = currentGroupId + } + var n = 0 + for (profile in results) { + ProfileManager.createProfile(currentGroupId, profile) + n++ + } + importedN.addAndGet(n) + n + } else { onMainDispatcher { - val msg = getString(R.string.action_import_err) + "\n" + e.readableMessage - Toast.makeText(app, msg, Toast.LENGTH_SHORT).show() + Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() } + 0 + } + } catch (e: SubscriptionFoundException) { + startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = e.link.toUri() + }) + 0 + } catch (e: Throwable) { + Logs.w(e) + onMainDispatcher { + val msg = getString(R.string.action_import_err) + "\n" + e.readableMessage + Toast.makeText(app, msg, Toast.LENGTH_SHORT).show() } + 0 } } @@ -276,4 +322,10 @@ class ScannerActivity : ThemedActivity() { Toast.makeText(app, text, Toast.LENGTH_LONG).show() } } + + companion object { + // Bound for decoding imported images; large enough for ML Kit to read dense QR + // codes, small enough to avoid OOM on arbitrary full-resolution gallery photos. + private const val MAX_IMPORT_DIMEN = 2048 + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acd900ab3..bedec5863 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,8 @@ Import from file Point the camera at a QR code Toggle flashlight + Turn flashlight on + Turn flashlight off No QR code found in the selected image Successfully export! Failed to export. From ab5898a6847d4a2f5a0ac5ccb39772cb8ca19b51 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:02:00 -0400 Subject: [PATCH 4/4] review: import before finishing on live scan; torch a11y default in layout - Live scan now awaits importText() and then finish()es on the main thread, so importedN is populated before onDestroy() reads it (previously finished first, making the toast report '0 profile(s)'). - Layout torch FAB default contentDescription is now scan_torch_turn_on (matches the stateful runtime updates). --- .../io/nekohasekai/sagernet/ui/ScannerActivity.kt | 11 +++++++---- app/src/main/res/layout/layout_scanner.xml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt index 7d24681b3..e46d78c63 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt @@ -152,10 +152,13 @@ class ScannerActivity : ThemedActivity() { .addOnSuccessListener { barcodes -> val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue if (text != null && !finished.getAndSet(true)) { - // First successful scan wins: finish the activity and import in the - // background (the activity-scoped toast in onDestroy reports the count). - finish() - runOnDefaultDispatcher { importText(text) } + // First successful scan wins. Import first, then finish on the main + // thread, so importedN is populated before onDestroy() reads it for + // the "N profile(s)" toast (finishing first raced the background import). + runOnDefaultDispatcher { + importText(text) + onMainDispatcher { finish() } + } } } .addOnFailureListener { Logs.w(it) } diff --git a/app/src/main/res/layout/layout_scanner.xml b/app/src/main/res/layout/layout_scanner.xml index f4e3824fc..0473b8144 100644 --- a/app/src/main/res/layout/layout_scanner.xml +++ b/app/src/main/res/layout/layout_scanner.xml @@ -40,7 +40,7 @@ android:layout_height="wrap_content" android:layout_gravity="center_horizontal|bottom" android:layout_marginBottom="48dp" - android:contentDescription="@string/scan_toggle_torch" + android:contentDescription="@string/scan_torch_turn_on" android:src="@drawable/ic_baseline_flash_off_24" app:fabSize="normal" />