-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(android): drop HMS Scan Kit backend, keep pure ML Kit QR scanning #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,7 @@ | ||
| package com.chloemlla.synapse.mobile.ui | ||
|
|
||
| import android.Manifest | ||
| import android.app.Activity | ||
| import android.content.Context | ||
| import android.content.ContextWrapper | ||
| import android.content.pm.PackageManager | ||
| import android.graphics.Rect | ||
| import android.os.Bundle | ||
| import android.view.ViewGroup | ||
| import android.widget.FrameLayout | ||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||
| import androidx.activity.result.contract.ActivityResultContracts | ||
| import androidx.camera.core.CameraSelector | ||
|
|
@@ -50,31 +43,16 @@ import androidx.core.content.ContextCompat | |
| import androidx.lifecycle.compose.LocalLifecycleOwner | ||
| import com.google.mlkit.vision.barcode.BarcodeScanning | ||
| import com.google.mlkit.vision.common.InputImage | ||
| import com.huawei.hms.hmsscankit.RemoteView | ||
| import com.huawei.hms.ml.scan.HmsScan | ||
| import java.util.concurrent.Executors | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
|
|
||
| /** | ||
| * QR scan backends available to the Compose surface. | ||
| * | ||
| * HMS Scan Kit ([Hms]) is preferred (same free `scanplus` path as PiliPlus — | ||
| * no `agconnect-services.json` / AGConnect plugin). ML Kit + CameraX is the | ||
| * automatic fallback when HMS linkage or runtime setup fails. | ||
| */ | ||
| enum class QrScanBackend { | ||
| Hms, | ||
| MlKit, | ||
| } | ||
|
|
||
| /** | ||
| * Public scanner entry used by [SynapseMobileApp]. Keeps the ViewModel callback | ||
| * contract (`onQrCode: (String) -> Unit`) stable across backend swaps. | ||
| * Public scanner entry used by [SynapseMobileApp]. Handles the camera | ||
| * permission gate and delegates straight to the CameraX + ML Kit scanner. | ||
| */ | ||
| @Composable | ||
| fun PermissionAwareQrScanner( | ||
| modifier: Modifier = Modifier, | ||
| preferredBackend: QrScanBackend = QrScanBackend.Hms, | ||
| onQrCode: (String) -> Unit, | ||
| ) { | ||
| val context = LocalContext.current | ||
|
|
@@ -89,9 +67,8 @@ fun PermissionAwareQrScanner( | |
| } | ||
|
|
||
| if (hasPermission) { | ||
| DualBackendQrScanner( | ||
| MlKitCameraQrScanner( | ||
| modifier = modifier, | ||
| preferredBackend = preferredBackend, | ||
| onQrCode = onQrCode, | ||
| ) | ||
|
Comment on lines
+70
to
73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win 确认 此入口现在直接使用 如果登录流程要求 QR-only,请使用 依据本文件的 ML Kit 创建逻辑和 🤖 Prompt for AI Agents🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'QrScannerView|SynapseMobileApp' . || true
printf '%s\n' '--- relevant symbols and APIs ---'
rg -n -C 8 \
'MlKitCameraQrScanner|DisposableEffect|bindToLifecycle|ProcessCameraProvider|cameraProviderFuture|BarcodeScanning|getClient|analyzerExecutor|ImageAnalysis|Preview|BarcodeScanner|showScanner' \
android/app/src/main/java 2>/dev/null || trueRepository: Chloemlla/Synapse-Client Length of output: 50380 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- QrScannerView.kt implementation ---'
sed -n '109,195p' android/app/src/main/java/com/chloemlla/synapse/mobile/ui/QrScannerView.kt
printf '%s\n' '--- dependency declarations ---'
rg -n -C 3 \
'camera-(core|lifecycle|view)|cameraX|mlkit|barcode-scanning|play-services-mlkit' \
android --glob '*.gradle' --glob '*.gradle.kts' --glob 'libs.versions.toml' --glob 'gradle.properties' || true
printf '%s\n' '--- scanner lifecycle call sites ---'
rg -n -C 4 \
'PermissionAwareQrScanner\(|MlKitCameraQrScanner\(|acceptScannedPayload|setScannerVisible' \
android/app/src/main/java/com/chloemlla/synapse/mobile/ui/QrScannerView.kt \
android/app/src/main/java/com/chloemlla/synapse/mobile/ui/SynapseMobileApp.kt \
android/app/src/main/java/com/chloemlla/synapse/mobile/ui/SynapseLoginViewModel.ktRepository: Chloemlla/Synapse-Client Length of output: 12209 🌐 Web query:
💡 Result: The following summarizes the documentation and behavior for the requested Android components: ProcessCameraProvider.unbindAll: This method unbinds all UseCases previously bound to a LifecycleOwner and removes them from CameraX [1][2]. It initiates a close of every currently open camera and is required to be called on the main thread [1][2]. When rebinding new UseCases, unbinding is typically implicit, so calling unbindAll is generally only necessary when you want to explicitly disconnect all camera operations from the lifecycle [1][3]. ImageAnalysis.clearAnalyzer: This method removes a previously set analyzer from the ImageAnalysis use case [4][5]. Once called, data will stop streaming to the analyzer [4][5]. It is commonly used to stop analysis when the component is no longer needed or before setting a new analyzer [6]. ML Kit BarcodeScanner.close: The BarcodeScanner should be explicitly closed using the close method when it is no longer needed to release underlying resources [7][8]. Failure to do so may lead to memory leaks or resource exhaustion. Task.addOnCompleteListener Lifecycle/Cancellation: - Behavior: A Task is considered complete if it succeeds, fails, or is canceled [9][10]. The OnCompleteListener is scheduled to run on the main thread when this completion occurs [11][9]. - Lifecycle Management: To prevent callbacks from executing after an Activity or Fragment has been destroyed (which could cause exceptions if accessing closed resources), you should use Activity-scoped listeners [12][13]. Passing an Activity as the first argument to addOnCompleteListener(activity, listener) automatically removes the listener during the activity's onStop lifecycle method [12][9][10]. - Cancellation: Cancellation is supported by some Task types (e.g., CancellableTask). If a task is canceled, its listeners (including those added via addOnCompleteListener) will be notified, and the task's isCanceled state will return true [14][15]. Note that Task callbacks may still trigger after destruction if they are not correctly scoped, making Activity-scoped listeners the recommended mitigation [13]. Citations:
在 组件移除时, 🤖 Prompt for AI Agents |
||
| } else { | ||
|
|
@@ -129,180 +106,8 @@ fun PermissionAwareQrScanner( | |
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun DualBackendQrScanner( | ||
| modifier: Modifier = Modifier, | ||
| preferredBackend: QrScanBackend, | ||
| onQrCode: (String) -> Unit, | ||
| ) { | ||
| var forceMlKit by remember(preferredBackend) { mutableStateOf(false) } | ||
| val activeBackend = when { | ||
| forceMlKit -> QrScanBackend.MlKit | ||
| preferredBackend == QrScanBackend.Hms && HmsScanAvailability.isUsable() -> QrScanBackend.Hms | ||
| else -> QrScanBackend.MlKit | ||
| } | ||
|
|
||
| when (activeBackend) { | ||
| QrScanBackend.Hms -> { | ||
| HmsRemoteQrScanner( | ||
| modifier = modifier, | ||
| onQrCode = onQrCode, | ||
| onBackendUnavailable = { | ||
| // Runtime / linkage failure → fall back to CameraX + ML Kit. | ||
| forceMlKit = true | ||
| }, | ||
| ) | ||
| } | ||
| QrScanBackend.MlKit -> { | ||
| MlKitCameraQrScanner( | ||
| modifier = modifier, | ||
| onQrCode = onQrCode, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Detects whether the free HMS Scan Kit (`scanplus`) classes are loadable. | ||
| * No AppGallery / AGConnect secrets are required for this check. | ||
| */ | ||
| internal object HmsScanAvailability { | ||
| fun isUsable(): Boolean = runCatching { | ||
| Class.forName("com.huawei.hms.hmsscankit.RemoteView") | ||
| Class.forName("com.huawei.hms.ml.scan.HmsScan") | ||
| true | ||
| }.getOrDefault(false) | ||
| } | ||
|
|
||
| /** | ||
| * Huawei Scan Kit [RemoteView] embedded in Compose via [AndroidView]. | ||
| * | ||
| * Free/public `com.huawei.hms:scanplus` path — no `agconnect-services.json`. | ||
| * Lifecycle callbacks are forwarded from the host Activity when available. | ||
| */ | ||
| @Composable | ||
| private fun HmsRemoteQrScanner( | ||
| modifier: Modifier = Modifier, | ||
| onQrCode: (String) -> Unit, | ||
| onBackendUnavailable: () -> Unit, | ||
| ) { | ||
| val context = LocalContext.current | ||
| // RemoteView.Builder.setContext requires an Activity (not a bare Context). | ||
| val activity = remember(context) { context.findActivity() } | ||
| val consumed = remember { AtomicBoolean(false) } | ||
| val remoteViewHolder = remember { arrayOfNulls<RemoteView>(1) } | ||
| val unavailableReported = remember { AtomicBoolean(false) } | ||
|
|
||
| fun reportUnavailable(host: FrameLayout? = null) { | ||
| if (!unavailableReported.compareAndSet(false, true)) return | ||
| // Defer Compose state writes off the AndroidView factory path. | ||
| val postTarget = host ?: activity?.window?.decorView | ||
| if (postTarget != null) { | ||
| postTarget.post { onBackendUnavailable() } | ||
| } else { | ||
| onBackendUnavailable() | ||
| } | ||
| } | ||
|
|
||
| // Without a host Activity HMS cannot build RemoteView — fall back immediately. | ||
| if (activity == null) { | ||
| DisposableEffect(Unit) { | ||
| onBackendUnavailable() | ||
| onDispose { } | ||
| } | ||
| return | ||
| } | ||
|
|
||
| DisposableEffect(activity) { | ||
| val view = remoteViewHolder[0] | ||
| try { | ||
| view?.onStart() | ||
| view?.onResume() | ||
| } catch (_: Throwable) { | ||
| reportUnavailable() | ||
| } | ||
| onDispose { | ||
| try { | ||
| remoteViewHolder[0]?.onPause() | ||
| } catch (_: Throwable) { | ||
| } | ||
| try { | ||
| remoteViewHolder[0]?.onStop() | ||
| } catch (_: Throwable) { | ||
| } | ||
| try { | ||
| remoteViewHolder[0]?.onDestroy() | ||
| } catch (_: Throwable) { | ||
| } | ||
| remoteViewHolder[0] = null | ||
| } | ||
| } | ||
|
|
||
| AndroidView( | ||
| modifier = modifier | ||
| .fillMaxWidth() | ||
| .height(280.dp) | ||
| .clip(RoundedCornerShape(16.dp)) | ||
| .background(MaterialTheme.colorScheme.surfaceContainer), | ||
| factory = { viewContext -> | ||
| val host = FrameLayout(viewContext).apply { | ||
| layoutParams = ViewGroup.LayoutParams( | ||
| ViewGroup.LayoutParams.MATCH_PARENT, | ||
| ViewGroup.LayoutParams.MATCH_PARENT, | ||
| ) | ||
| } | ||
| try { | ||
| val metrics = viewContext.resources.displayMetrics | ||
| val scanFrameSize = (240 * metrics.density).toInt() | ||
| val width = metrics.widthPixels.coerceAtLeast(1) | ||
| // RemoteView uses screen metrics for the bounding box; for the | ||
| // embedded 280dp preview use a centered square within the view. | ||
| val height = (280 * metrics.density).toInt().coerceAtLeast(1) | ||
| val rect = Rect( | ||
| width / 2 - scanFrameSize / 2, | ||
| height / 2 - scanFrameSize / 2, | ||
| width / 2 + scanFrameSize / 2, | ||
| height / 2 + scanFrameSize / 2, | ||
| ) | ||
| val remoteView = RemoteView.Builder() | ||
| .setContext(activity) | ||
| .setBoundingBox(rect) | ||
| .setFormat(HmsScan.QRCODE_SCAN_TYPE) | ||
| .build() | ||
| remoteView.setOnResultCallback { results -> | ||
| val raw = results | ||
| ?.firstOrNull() | ||
| ?.getOriginalValue() | ||
| ?.takeIf { it.isNotBlank() } | ||
| if (raw != null && consumed.compareAndSet(false, true)) { | ||
| onQrCode(raw) | ||
| } | ||
| } | ||
| remoteView.onCreate(Bundle()) | ||
| remoteViewHolder[0] = remoteView | ||
| host.addView( | ||
| remoteView, | ||
| FrameLayout.LayoutParams( | ||
| ViewGroup.LayoutParams.MATCH_PARENT, | ||
| ViewGroup.LayoutParams.MATCH_PARENT, | ||
| ), | ||
| ) | ||
| try { | ||
| remoteView.onStart() | ||
| remoteView.onResume() | ||
| } catch (_: Throwable) { | ||
| reportUnavailable(host) | ||
| } | ||
| } catch (_: Throwable) { | ||
| reportUnavailable(host) | ||
| } | ||
| host | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Legacy CameraX + ML Kit barcode path retained as fallback when HMS is unavailable. | ||
| * Primary CameraX + ML Kit barcode scanning path. | ||
| */ | ||
| @Composable | ||
| @androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class]) | ||
|
|
@@ -382,9 +187,3 @@ private fun MlKitCameraQrScanner( | |
| }, | ||
| ) | ||
| } | ||
|
|
||
| private tailrec fun Context.findActivity(): Activity? = when (this) { | ||
| is Activity -> this | ||
| is ContextWrapper -> baseContext.findActivity() | ||
| else -> null | ||
| } | ||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔵 Trivial
在合并前完成发布包和真机扫描验证。
删除
scanplus后,扫描器只依赖 CameraX 1.4.2 和 ML Kit 17.3.0。请完成 CI 单元测试、lint、release APK 构建,并在真实设备上验证权限授予、扫描、关闭扫描器后再次打开,以及onQrCode回调仍到达SynapseMobileApp.kt。依据 PR objectives 和
QrScannerView.kt的 CameraX/ML Kit 使用。🤖 Prompt for AI Agents