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:
+8
-5
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "com.example.deviceagent"
|
applicationId "com.example.deviceagent"
|
||||||
minSdk 29 // Android 10;剪贴板限制就是从这一版开始的
|
minSdk 29 // Android 10;剪贴板限制就是从这一版开始的
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 4
|
versionCode 5
|
||||||
versionName "1.3"
|
versionName "1.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -42,8 +42,11 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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 'androidx.core:core-ktx:1.13.1'
|
||||||
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- 自己调系统安装器安装 APK(这正是绕开 MIUI「USB 安装」拦截的关键) -->
|
<!-- 自己调系统安装器安装 APK(这正是绕开 MIUI「USB 安装」拦截的关键) -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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,
|
Android 11+ 默认限制了软件包可见性(不给的话 getPackageInfo 一律抛 NameNotFound,
|
||||||
@@ -59,6 +62,15 @@
|
|||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:excludeFromRecents="true" />
|
android:excludeFromRecents="true" />
|
||||||
|
|
||||||
|
<!-- 扫码配置:扫平台设备池里生成的配置二维码,一次写入地址/令牌/指纹
|
||||||
|
(设备还没连上平台、或人在机器旁不想插线时用;已连上平台的也可以用
|
||||||
|
ConfigActivity 走 adb 下发,两条路等价) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ScanActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="扫码配置"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
<!-- 一次性配置下发(平台用 adb 推地址与令牌,省得每台手输) -->
|
<!-- 一次性配置下发(平台用 adb 推地址与令牌,省得每台手输) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".ConfigActivity"
|
android:name=".ConfigActivity"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class MainActivity : Activity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
refreshDeviceInfo()
|
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 }
|
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() })
|
toolbar.addView(btn("刷新清单") { refreshList() })
|
||||||
|
|
||||||
tvStatus = TextView(this).apply {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user