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..e46d78c63 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,69 @@ 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,184 +78,257 @@ 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() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.scanner_menu, menu) - return true + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + requestCamera.launch(Manifest.permission.CAMERA) + } } - val importCodeFile = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { - runOnDefaultDispatcher { - try { - it.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 result = CodeUtils.parseCodeResult(bitmap) - onMainDispatcher { - onScanResultCallback(result, true) - } - } + 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 + binding.fabTorch.contentDescription = getString(R.string.scan_torch_turn_on) } catch (e: Exception) { Logs.w(e) - onMainDispatcher { - Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() - } + 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 -> + val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (text != null && !finished.getAndSet(true)) { + // 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) } + .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 + ) + // 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 { + menuInflater.inflate(R.menu.scanner_menu, menu) + return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return if (item.itemId == R.id.action_import_file) { - startFilesForResult(importCodeFile, "image/*") + importCodeFile.launch("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 - */ - override fun onScanResultCallback(result: Result?): Boolean { - return onScanResultCallback(result, false) - } - - fun onScanResultCallback(result: Result?, multi: Boolean): Boolean { - if (!multi && finished.getAndSet(true)) return true - if (!multi) finish() + private val importCodeFile = registerForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isEmpty()) return@registerForActivityResult runOnDefaultDispatcher { + var foundQr = false + var imported = 0 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 + uris.forEachTry { uri -> + val bitmap = decodeBoundedBitmap(uri) + val barcodes = try { + com.google.android.gms.tasks.Tasks.await( + barcodeScanner.process(InputImage.fromBitmap(bitmap, 0)) + ) + } finally { + bitmap.recycle() } - - for (profile in results) { - ProfileManager.createProfile(currentGroupId, profile) - importedN.addAndGet(1) + val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (text != null) { + foundQr = true + imported += importText(text) } - } else { + } + if (!foundQr) { onMainDispatcher { - Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() + Toast.makeText(app, R.string.scan_no_qr_found, Toast.LENGTH_LONG).show() } } - } catch (e: SubscriptionFoundException) { - startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = e.link.toUri() - }) - } catch (e: Throwable) { + } catch (e: Exception) { Logs.w(e) onMainDispatcher { - var text = getString(R.string.action_import_err) - text += "\n" + e.readableMessage - Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() } + } finally { + // Only finish once the import actually completed (importText is awaited + // above) and at least one profile was created. + if (imported > 0) onMainDispatcher { finish() } } } - return true - } - - /** - * Initialize CameraScan - */ - fun initCameraScan() { - cameraScan = DefaultCameraScan(this, binding.previewView) - cameraScan.setAnalyzer(QRCodeAnalyzer()) - cameraScan.setOnScanResultCallback(this) - cameraScan.setNeedAutoZoom(true) } /** - * Start camera preview + * 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. */ - fun startCamera() { - if (PermissionUtils.checkPermission(this, Manifest.permission.CAMERA)) { - cameraScan.startCamera() + 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) + ) + } + } } 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) + // 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") } } /** - * Callback result for requesting Camera permission - * @param permissions - * @param grantResults + * 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. */ - fun requestCameraPermissionResult(permissions: Array, grantResults: IntArray) { - if (PermissionUtils.requestPermissionsResult( - Manifest.permission.CAMERA, permissions, grantResults - ) - ) { - startCamera() - } else { - finish() + 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 { + 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 } } 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 + + 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/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..0473b8144 100644 --- a/app/src/main/res/layout/layout_scanner.xml +++ b/app/src/main/res/layout/layout_scanner.xml @@ -1,28 +1,47 @@ - - - - + + - \ No newline at end of file + android:layout_gravity="center_horizontal|top" + android:layout_marginTop="120dp" + android:background="#66000000" + 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" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1069dd9c8..bedec5863 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,11 @@ Export Import from Clipboard 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. Successfully import!