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 + } +}