diff --git a/app/build.gradle b/app/build.gradle
index 158d87d..61f59f3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "com.example.deviceagent"
minSdk 29 // Android 10;剪贴板限制就是从这一版开始的
targetSdk 34
- versionCode 4
- versionName "1.3"
+ versionCode 5
+ versionName "1.4"
}
buildTypes {
@@ -42,8 +42,11 @@ android {
}
dependencies {
- // 刻意只引一个 androidx.core:只为 FileProvider(把下载的 APK 授权给系统安装器读)。
- // HTTP 用 HttpURLConnection、JSON 用 org.json(都是系统内置),不引网络/序列化库——
- // 依赖越少,构建越快、出问题越好排查。
+ // 刻意只引两个:
+ // androidx.core —— 只为 FileProvider(把下载的 APK 授权给系统安装器读)
+ // zxing core —— 扫码配置用。**刻意不用 ML Kit**:那要 Google Play 服务,
+ // 国内机型上普遍没有,扫码会直接不可用。
+ // HTTP 用 HttpURLConnection、JSON 用 org.json(系统内置),不引网络/序列化库。
implementation 'androidx.core:core-ktx:1.13.1'
+ implementation 'com.google.zxing:core:3.5.3'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 79c76f7..eea74dd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,9 @@
+
+
+
+
+
, res: IntArray) {
+ super.onRequestPermissionsResult(code, perms, res)
+ if (code != REQ_CAMERA) return
+ if (res.isNotEmpty() && res[0] == PackageManager.PERMISSION_GRANTED) {
+ pendingHolder?.let { openCamera(it) } // 拿到权限了,现在才开相机
+ } else {
+ Toast.makeText(this, "没有相机权限,扫不了码(也可以用「手动配置」)",
+ Toast.LENGTH_LONG).show()
+ finish()
+ }
+ }
+
+ // ================== 相机 ==================
+ // ⚠️ 时序:surfaceCreated 会在**权限回调之前**触发 —— 那时还没相机权限,
+ // 直接开相机会报 "Fail to connect to camera service" 并且流程就断了
+ // (实机踩过)。所以这里只记下 holder,等权限到手(onRequestPermissionsResult)
+ // 或已有权限时才真正打开。
+ private var pendingHolder: SurfaceHolder? = null
+
+ private fun hasCameraPermission() =
+ checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+
+ override fun surfaceCreated(holder: SurfaceHolder) {
+ pendingHolder = holder
+ if (hasCameraPermission()) openCamera(holder)
+ }
+
+ override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, hh: Int) {}
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ pendingHolder = null
+ closeCamera()
+ }
+
+ @Suppress("DEPRECATION")
+ private fun openCamera(holder: SurfaceHolder) {
+ if (camera != null) return
+ try {
+ val cam = Camera.open()
+ val params = cam.parameters
+ // 预览尺寸取个中等大小:太大解码慢、太小看不清
+ params.previewSize?.let { ps ->
+ val best = cam.parameters.supportedPreviewSizes
+ .filter { it.width <= 1280 && it.height <= 960 }
+ .maxByOrNull { it.width * it.height }
+ ?: ps
+ // 注意:setPreviewSize 收的是两个 int(不是 Size 对象),
+ // 所以不能用 `params.previewSize = best`(Kotlin 那里当只读属性)
+ params.setPreviewSize(best.width, best.height)
+ }
+ params.previewFormat = android.graphics.ImageFormat.NV21
+ params.focusMode = if (params.supportedFocusModes
+ .contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE))
+ Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE else params.focusMode
+ cam.parameters = params
+ cam.setPreviewDisplay(holder)
+ cam.setDisplayOrientation(90) // 竖屏预览
+ cam.setPreviewCallback { data, c ->
+ if (done) return@setPreviewCallback
+ val size = c.parameters.previewSize ?: return@setPreviewCallback
+ decodeAsync(data, size.width, size.height)
+ }
+ cam.startPreview()
+ camera = cam
+ Log.i(TAG, "相机已启动,预览 ${params.previewSize}")
+ } catch (e: Exception) {
+ Log.e(TAG, "打开相机失败: ${e.message}")
+ Toast.makeText(this, "打不开相机:${e.message}", Toast.LENGTH_LONG).show()
+ finish()
+ }
+ }
+
+ private fun closeCamera() {
+ try {
+ camera?.setPreviewCallback(null)
+ camera?.stopPreview()
+ camera?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "关闭相机异常: ${e.message}")
+ }
+ camera = null
+ }
+
+ /** 预览帧解码:必须离开回调线程(在回调里解码会卡住预览) */
+ private var busy = false
+ private fun decodeAsync(data: ByteArray, w: Int, h: Int) {
+ if (busy || done) return
+ busy = true
+ val copy = data.copyOf()
+ worker.execute {
+ try {
+ val source = PlanarYUVLuminanceSource(copy, w, h, 0, 0, w, h, false)
+ val bitmap = BinaryBitmap(HybridBinarizer(source))
+ val result = reader.decodeWithState(bitmap)
+ onScanned(result.text)
+ } catch (e: Exception) {
+ // NotFoundException:这一帧没扫到,继续下一帧(正常现象,不报错)
+ } finally {
+ reader.reset()
+ busy = false
+ }
+ }
+ }
+
+ // ================== 扫到了 ==================
+ private fun onScanned(text: String) {
+ if (done) return
+ val j = try {
+ JSONObject(text)
+ } catch (e: Exception) {
+ runOnUiThread {
+ hint.text = "这不是平台的配置二维码,请扫「设备池 → 二维码」生成的那个"
+ }
+ return
+ }
+ val server = j.optString("server")
+ val token = j.optString("token")
+ if (server.isEmpty() || token.isEmpty()) {
+ runOnUiThread { hint.text = "二维码内容不完整(缺平台地址或令牌)" }
+ return
+ }
+ done = true
+ val fp = j.optString("fingerprint")
+ Prefs.save(this, server, token, fp.ifEmpty { null })
+ Log.i(TAG, "扫码配置完成: server=$server has_token=${token.isNotEmpty()} fp=$fp")
+ runOnUiThread {
+ Toast.makeText(this, "配置完成:${j.optString("name").ifEmpty { "这台设备" }} → $server",
+ Toast.LENGTH_LONG).show()
+ finish()
+ }
+ }
+
+ override fun onDestroy() {
+ done = true
+ closeCamera()
+ worker.shutdownNow()
+ super.onDestroy()
+ }
+
+ companion object {
+ const val TAG = "DeviceAgent"
+ private const val REQ_CAMERA = 101
+ }
+}