Skip to content
Merged
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
6 changes: 1 addition & 5 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,8 @@ dependencies {
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
// ML Kit barcode retained as dual-backend fallback when HMS Scan Kit is unavailable.
// ML Kit on-device barcode scanning (CameraX frames + album decode).
implementation("com.google.mlkit:barcode-scanning:17.3.0")
Comment on lines +181 to 182

Copy link
Copy Markdown

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/app/build.gradle.kts` around lines 181 - 182, 在合并前完成发布验证:运行 CI 单元测试和
lint,构建 release APK,并使用真实设备验证权限授予、CameraX/ML Kit 扫描、关闭后再次打开扫描器,以及
QrScannerView.kt 的 onQrCode 回调仍正确到达 SynapseMobileApp.kt。

// Huawei Scan Kit full/public SDK (scanplus): camera RemoteView + bitmap decode.
// Independent path — no agconnect-services.json / AGConnect plugin required.
// Artifact lives on https://developer.huawei.com/repo/ (see settings.gradle.kts).
implementation("com.huawei.hms:scanplus:2.15.0.301")

testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
Expand Down
8 changes: 0 additions & 8 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,6 @@
-dontwarn com.google.mlkit.**
-dontwarn com.google.android.gms.internal.mlkit_vision_barcode.**

# Huawei Scan Kit (scanplus free/public SDK) uses native/reflection entry points.
# Keep HMS scan packages so R8 does not strip RemoteView / decoder bindings.
-keep class com.huawei.hms.hmsscankit.** { *; }
-keep class com.huawei.hms.ml.scan.** { *; }
-keep class com.huawei.hms.mlsdk.** { *; }
-dontwarn com.huawei.hms.**
-dontwarn com.huawei.agconnect.**

# OkHttp/Okio are direct networking dependencies and may reference optional platforms.
-dontwarn okhttp3.**
-dontwarn okio.**
Expand Down
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
Expand Down Expand Up @@ -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
Expand All @@ -89,9 +67,8 @@ fun PermissionAwareQrScanner(
}

if (hasPermission) {
DualBackendQrScanner(
MlKitCameraQrScanner(
modifier = modifier,
preferredBackend = preferredBackend,
onQrCode = onQrCode,
)
Comment on lines +70 to 73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

确认 onQrCode 只接收 QR 条码。

此入口现在直接使用 MlKitCameraQrScanner。实现调用 BarcodeScanning.getClient(),不传入 BarcodeScannerOptions,随后只读取 barcode.rawValue,不检查 barcode.format。如果 ML Kit 17.3.0 的默认客户端返回多种条码,非 QR 条码也会传给 onQrCode

如果登录流程要求 QR-only,请使用 BarcodeScannerOptions 设置 Barcode.FORMAT_QR_CODE,或在回调前检查 barcode.format。同时确认 SynapseMobileApp.kt 的下游逻辑没有依赖重复校验。

依据本文件的 ML Kit 创建逻辑和 onQrCode 回调契约。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/app/src/main/java/com/chloemlla/synapse/mobile/ui/QrScannerView.kt`
around lines 70 - 73, 确保 MlKitCameraQrScanner 的扫描配置仅接受 QR 条码:使用
BarcodeScannerOptions 将格式限制为 Barcode.FORMAT_QR_CODE,或在触发 onQrCode 前校验
barcode.format;同时避免在 SynapseMobileApp.kt 中重复实现同一校验。

🩺 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 || true

Repository: 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.kt

Repository: Chloemlla/Synapse-Client

Length of output: 12209


🌐 Web query:

Official Android documentation for CameraX ProcessCameraProvider.unbindAll(), ImageAnalysis.clearAnalyzer(), ML Kit BarcodeScanner close(), and Task addOnCompleteListener cancellation/lifecycle behavior

💡 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:


MlKitCameraQrScanner 销毁时完整停止 CameraX 和 ML Kit。

组件移除时,DisposableEffect 只关闭 analyzerExecutorcameraProviderFuture 的 listener 仍可能在销毁后绑定 PreviewImageAnalysis。请使用 disposed 标志阻止该绑定,并在销毁时清理分析器、解绑用例、调用 BarcodeScanner.close(),最后关闭 executor。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/app/src/main/java/com/chloemlla/synapse/mobile/ui/QrScannerView.kt`
around lines 70 - 73, Update MlKitCameraQrScanner’s DisposableEffect cleanup to
mark the component disposed, prevent cameraProviderFuture’s listener from
binding Preview or ImageAnalysis after disposal, then clear the analyzer, unbind
all CameraX use cases, close BarcodeScanner, and finally shut down
analyzerExecutor.

} else {
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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.

6 changes: 0 additions & 6 deletions android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
// Huawei HMS public Maven (Scan Kit scanplus free/public SDK).
// Independent path — no AGConnect plugin or agconnect-services.json.
maven {
name = "HuaweiMaven"
url = uri("https://developer.huawei.com/repo/")
}
maven {
name = "GitHubPackagesProjectLumen"
url = uri("https://maven.pkg.github.com/Chloemlla/Project-Lumen")
Expand Down
Loading