feat: 扫码配置——扫平台上生成的二维码,一次配好(v1.4)

场景:设备还没接进平台、或人站在机架前不想插线时,原来得在手机上手动输平台地址和
令牌(还可能漏指纹)。现在平台「设备池」里点某台设备的「二维码」,手机上点
「扫码配置」扫一下即完成。

- ScanActivity:Camera1 + **ZXing core**(刻意不用 ML Kit —— 那要 Google Play 服务,
  国内机型普遍没有);QR 的定位图案让 ZXing 自带旋转不变性,不用手动转 YUV 帧
- 二维码内容(平台侧生成):{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}
  —— 指纹只能由平台给(应用读不到 ro.serialno,见 Prefs 说明),扫码正好是最省事的传递方式
- MainActivity 加「扫码配置」按钮(原「配置」改名「手动配置」)

实机踩到的坑(已修):
- **相机必须在拿到权限后再打开**:surfaceCreated 早于权限回调触发,先开相机会报
  "Fail to connect to camera service" 并把流程直接断掉(第一次实测就是这么挂的)
  → 改成 surfaceCreated 只记下 holder,授权回调里再开
- `params.previewSize = size` 编译不过:setPreviewSize 收的是两个 int 不是 Size 对象

实测(A07 / USB 连接):扫码 → 相机启动 → 解码 → 保存 → 立刻拉清单,
平台认出「A07」,全程无需打字、无需 adb。
This commit is contained in:
2026-09-13 16:42:07 +08:00
parent 2580cfaead
commit f100301a56
4 changed files with 244 additions and 7 deletions
+8 -5
View File
@@ -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'
}
+12
View File
@@ -14,6 +14,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 自己调系统安装器安装 APK(这正是绕开 MIUI「USB 安装」拦截的关键) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- 扫配置二维码 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!--
查询任意已安装应用的版本号 —— 用来在商店列表里显示「已安装 / 可更新 / 安装」。
Android 11+ 默认限制了软件包可见性(不给的话 getPackageInfo 一律抛 NameNotFound,
@@ -59,6 +62,15 @@
android:launchMode="singleTop"
android:excludeFromRecents="true" />
<!-- 扫码配置:扫平台设备池里生成的配置二维码,一次写入地址/令牌/指纹
(设备还没连上平台、或人在机器旁不想插线时用;已连上平台的也可以用
ConfigActivity 走 adb 下发,两条路等价) -->
<activity
android:name=".ScanActivity"
android:exported="false"
android:label="扫码配置"
android:screenOrientation="portrait" />
<!-- 一次性配置下发(平台用 adb 推地址与令牌,省得每台手输) -->
<activity
android:name=".ConfigActivity"
@@ -49,7 +49,7 @@ class MainActivity : Activity() {
override fun onResume() {
super.onResume()
refreshDeviceInfo()
if (Prefs.configured(this)) refreshList() else status("还没配置平台地址和令牌,点上面「配置」")
if (Prefs.configured(this)) refreshList() else status("还没配置:点「扫码配置」扫平台的二维码,或「手动配置」自己填")
}
// ================== 界面 ==================
@@ -70,7 +70,8 @@ class MainActivity : Activity() {
}
val toolbar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
toolbar.addView(btn("配置") { showConfigDialog() })
toolbar.addView(btn("扫码配置") { startActivity(Intent(this, ScanActivity::class.java)) })
toolbar.addView(btn("手动配置") { showConfigDialog() })
toolbar.addView(btn("刷新清单") { refreshList() })
tvStatus = TextView(this).apply {
@@ -0,0 +1,221 @@
package com.example.deviceagent
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Color
import android.hardware.Camera
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import org.json.JSONObject
import java.util.concurrent.Executors
/**
* 扫配置二维码:平台「设备池」里点某台设备的「二维码」生成,手机扫一下即完成配置
* (写入平台地址 + 设备令牌 + 这台设备的指纹),不用在手机上打字、也不用连 adb。
*
* 为什么这么设计:应用身份读不到 ro.serialno(见 Prefs 的说明),指纹只能由平台给;
* 而人拿着手机站在电脑前,扫码是最省事的传递方式。设备已经连上平台时,平台也可以
* 直接用 adb 下发同样的配置(ConfigActivity),两条路等价、互为补位。
*
* 实现:Camera1 API + ZXing core(**不依赖 Google Play 服务**,国内机型也能用)。
* QR 的定位图案让 ZXing 自带旋转不变性,不用手动转 YUV 帧。
*/
class ScanActivity : Activity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var hint: TextView
private var camera: Camera? = null
private var done = false
private val worker = Executors.newSingleThreadExecutor()
private val reader = MultiFormatReader().apply {
setHints(mapOf(
com.google.zxing.DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
com.google.zxing.DecodeHintType.TRY_HARDER to true))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
buildUi()
if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.CAMERA), REQ_CAMERA)
}
}
private fun buildUi() {
val root = FrameLayout(this)
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView, FrameLayout.LayoutParams(-1, -1))
hint = TextView(this).apply {
text = "把平台上的配置二维码对准摄像头"
setTextColor(Color.WHITE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setBackgroundColor(Color.parseColor("#99000000"))
setPadding(24, 18, 24, 18)
gravity = Gravity.CENTER
}
root.addView(hint, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER_HORIZONTAL).apply {
topMargin = 80
})
setContentView(root)
}
override fun onRequestPermissionsResult(code: Int, perms: Array<out String>, 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
}
}