Android 系统级设备应用踩坑实录:sharedUserId 签名、SDK 授权失败 -4、开机自启与 U 盘 OTA 升级
写在前面
过去一年我在做一台生物识别智能终端的 Android 应用开发:RK 主板、虹膜 + 人脸双摄、NFC 刷卡、485 串口锁板,2GB 内存,Android 11,应用声明了 sharedUserId="android.uid.system",跑在系统层。
这段经历让我反复意识到一件事:行业设备应用(工控/IoT/自助终端)和消费级 App 是两套开发范式。
消费 App 的世界里,你只管声明权限、过审核、上商店;设备是别人的,系统 API 里的 hide、@SystemApi 与你无关。而在行业设备上,主板厂商给你 platform key,你可以申请 REBOOT、WRITE_SECURE_SETTINGS 这种"特权签名级"权限,可以静默装 APK、可以调厂商 SDK 重启整机。但硬币的另一面是:签名、授权、权限模型全部变得脆弱且互相耦合------一个权限没拿到,SDK 报的错误码会把你引向完全错误的排查方向。
这篇文章把我们踩过的几个典型坑整理成合集,每个坑独立成节:问题现象 → 根因 → 排查过程 → 最终方案。素材全部来自真实项目记录,希望能帮后来人少熬几个通宵。
坑一:sharedUserId="android.uid.system" 与签名------INSTALL_FAILED_SHARED_USER_INCOMPATIBLE
问题现象
项目接入虹膜设备时,需求要求应用具备系统级能力(静默装包、重启、读写 secure settings)。方案是在 Manifest 里声明:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:sharedUserId="android.uid.system">
然后用 adb install 装包,直接报错:
yaml
Failure [INSTALL_FAILED_SHARED_USER_INCOMPATIBLE:
Reconciliation failed...: has no signatures that match those in shared user android.uid.system]
根因分析
sharedUserId 的语义是:声明同一个 userId 的所有应用共享同一个 Linux UID、跑在同一个进程空间里 。系统允许这么做的前提是------这些应用必须用同一份证书签名 。android.uid.system 是系统 UID,你的 APK 想和系统共享 UID,就必须用设备厂商的 platform key 签名,而不是你自己 keytool 生成的 debug/release 签名。
这个错误的迷惑性在于:它在编译期完全无感,AS 里 Run 都能跑起来(模拟器上系统签名放宽),只有装到真机时才炸。而且报错信息里没有"请用 platform key"这种提示,第一次遇到很容易去查 manifest merger 冲突。
最终方案
找主板厂商要 platform key(通常是 platform.pk8 + platform.x509.pem,或者厂商已经封装好的 jks),然后在 Gradle 里配一个 system 签名配置,debug 和 release 都要用它:
kotlin
// app/build.gradle.kts
android {
signingConfigs {
create("system") {
storeFile = file("../signAPK/platform.jks") // 厂商提供的 platform key
storePassword = "******"
keyAlias = "******"
keyPassword = "******"
}
}
buildTypes {
debug {
// 关键点:debug 也要用系统签名,否则本地调试装不上
signingConfig = signingConfigs.getByName("system")
}
release {
signingConfig = signingConfigs.getByName("system")
// ...
}
}
}
经验总结
- 一旦声明
sharedUserId="android.uid.system",整个团队的所有构建(包括 CI 打 debug 包)都必须用 platform key 签名,否则真机安装一律失败。这条要写进项目 README 第一行。 - 拿到系统签名后,你才有底气在 Manifest 里申请特权权限,注意这些权限在普通应用上声明会被
aapt/Lint 标记,需要tools:ignore安抚:
xml
<uses-permission
android:name="android.permission.REBOOT"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.SET_TIME"
tools:ignore="ProtectedPermissions" />
- 如果只有
platform.pk8没有 jks,可以用keytool -importkeystore或厂商打包工具转换;转好后建议单独存放在仓库的独立目录并严格控制访问权限,别把 platform key 的密码提交进 git。
坑二:FaceRecognize.initAuth() 返回 -4------授权文件明明在,为什么报授权失败?
这是整个项目里排查时间最长、教训最深刻的一个坑。
问题现象
接入厂商人脸 SDK 时,调用初始化授权接口 FaceRecognize.initAuth() 返回 -4,厂商文档里 -4 的含义是"授权失败 / 授权文件不存在或过期"。
我们第一反应是查授权文件:用 adb shell ls 一看,授权文件明明存在,大小正常,也没过期。重新推送授权文件、重启设备、重装 APK,全部无效,依旧 -4。
根因分析:错误的错误码把你带偏
最后靠打诊断日志才定位到真相:根因是 MANAGE_EXTERNAL_STORAGE 权限没拿到,根本不是授权文件的问题。
厂商 SDK 内部的授权校验逻辑大致是这样的(伪代码):
scss
boolean isAuth() {
// 新版授权:读 /sdcard/Android/data/<pkg>/files/ECAuth/ 下的授权文件
if (canReadExternalStorage() && checkNewAuthFile()) return true;
// fallback:旧版授权,读 /sdcard/<VendorFace>/ 下的另一套授权文件
return checkLegacyAuthFile();
}
Android 11 引入分区存储(Scoped Storage)后,应用没有 MANAGE_EXTERNAL_STORAGE 权限就读不到 /sdcard/Android/data/<pkg>/files/ 之外的很多路径,SDK 内部的 isAuth() 返回 false,于是走了 fallback 分支去找旧版授权文件------旧版文件当然不存在,最终对外抛出的就是 -4。
也就是说:权限缺失 → 新版授权读不到 → fallback 到旧版路径 → 报"授权文件不存在"。错误码描述的是最后一环的现象,和根因隔了两层。
排查方法论:先诊断,再动手
这次之后我们沉淀了一条铁律:SDK 报错先别信错误码的字面含义,先写诊断函数把关键状态全打出来 。项目里给 ViewModel 加了一个 diagnoseLicenseStatus(),在每次初始化前执行:
kotlin
fun diagnoseLicenseStatus() {
LogUtil.i(TAG, "===== License Diagnosis Start =====")
val authMgr = AuthManager.getInstance()
authMgr.init(context)
// 1. SDK 自己怎么看授权状态
LogUtil.i(TAG, "isAuth() = ${authMgr.isAuth()}")
LogUtil.i(TAG, "localAuthState = ${authMgr.getLocalAuthState()}")
// 2. 授权文件路径与内容,逐文件打印
val authDir = AuthManager.getAuthDir()
LogUtil.i(TAG, "authDir = $authDir")
File(authDir).listFiles()?.forEach { f ->
LogUtil.i(TAG, " ${f.name} (size=${f.length()})")
}
// 3. 存储权限状态------Android 11+ 关键
LogUtil.i(TAG, "isExternalStorageManager = " +
Environment.isExternalStorageManager())
LogUtil.i(TAG, "===== License Diagnosis End =====")
}
有了这段日志,"文件在但 SDK 说不在"和"权限不够读不到"一眼就能区分开。
最终方案:Android 11+ 的 MANAGE_EXTERNAL_STORAGE 申请
MANAGE_EXTERNAL_STORAGE 是特殊权限,不能用 requestPermissions() 申请,必须跳系统设置页让用户手动开:
kotlin
private fun ensureStoragePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!Environment.isExternalStorageManager()
) {
AlertDialog.Builder(this)
.setTitle("权限申请")
.setCancelable(false)
.setMessage("识别算法需要访问存储以读取授权文件,请前往设置开启")
.setPositiveButton("去开启") { _, _ ->
val intent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName")
)
startActivityForResult(intent, REQ_PERMISSION)
}
.show()
return
}
initSdk()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_PERMISSION) {
if (Environment.isExternalStorageManager()) initSdk()
else showError("存储权限被拒绝,算法无法初始化")
}
}
两个工程实践补充:
- 预初始化要检查权限前置条件 。我们在
Application里做了 SDK 后台预初始化来优化首次识别速度,预初始化入口必须先判断Environment.isExternalStorageManager()和isAuth(),条件不满足就跳过、由页面层拿到权限后再初始化,否则预初始化必失败还浪费一次耗时加载。 - 这类"权限 → SDK 内部多路径 fallback → 误导性错误码"的结构在厂商 SDK 里非常常见。凡是要读文件的 SDK 初始化报错,先查权限,再查路径,最后才怀疑文件本身。
【配图建议:一张排查决策流程图------SDK 报授权失败 → 检查存储权限 → 检查文件路径 → 检查文件有效期 → 联系厂商】
坑三:开机自启------BOOT_COMPLETED 的正确姿势与现实骨感
需求与现象
行业终端的标准需求:设备上电后应用自动起来,进到识别主界面,不能让用户去桌面点图标。
标准做法并不复杂:声明 RECEIVE_BOOT_COMPLETED 权限,静态注册一个 Receiver 监听 BOOT_COMPLETED:
kotlin
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
val startIntent = Intent(context, MainActivity::class.java).apply {
// 从广播上下文拉 Activity 必须加这个 flag
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(startIntent)
}
}
}
xml
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
需要注意的现实限制
代码简单,坑在系统限制上:
- Android 3.1+ 的 stopped state :应用安装后如果从未被用户手动启动过,处于 stopped 状态,静态注册的
BOOT_COMPLETED收不到。设备首次装机时必须先手动点开一次应用。 - Android 12+ 后台启动 Activity 限制 :从广播里直接
startActivity在原生 Android 12+ 上是受限的(Background activity starts 被禁止)。好在行业设备的 ROM 几乎都是厂商深度定制过的 AOSP,多数会放开这个限制------但一定要在你量产的那块板子上实测,别拿 Pixel 的行为当依据。 - 系统 UID 的额外好处 :因为我们是
android.uid.system,很多后台限制天然不适用,这也是系统签名方案的隐性收益之一。 - 开机自启只是兜底。真正的商用终端还会配厂商层的"开机启动白名单"或 watchdog 方案(如厂商系统设置里把应用设为默认 Launcher、或 init.rc 里拉起)。应用层 Receiver 和系统层配置是互补关系,不是替代关系,建议两条都做,任何一条失效都有备份。
经验总结
行业设备的"开机自启"和消费 App 的"保活"是两个命题:前者是合法需求且系统提供正规路径,后者是灰色对抗。别把消费端的"防杀进程"那一套黑魔法带进设备项目------你手里有 platform key 和厂商关系,走正门。
坑四:U 盘 OTA 升级------现场没有网,怎么更新程序?
场景
智能柜终端部署在工厂、园区,现场经常没有外网,MQTT/HTTP 远程升级不可用。运维人员拿着 U 盘去现场升级是最现实的方式。需求拆开是三件事:检测 U 盘挂载 → 列出根目录的 APK → 点击后调系统能力安装。
根因层面的难点
- U 盘挂载路径不固定 。Android 没有官方 API 枚举 USB 存储挂载点,RK 这类板子通常挂在
/mnt/media_rw/xxx或/storage/xxxx-xxxx下,不同固件版本路径还不一样。我们用的是主板厂商配套的 U 盘文件选择 SDK 来获取挂载根路径,没有这类 SDK 时可以退化为扫描/storage、/mnt/media_rw目录下可读子目录。 - 静默安装是特权能力 。普通应用装 APK 只能
Intent(ACTION_VIEW)弹系统安装器,用户要点"安装"。设备应用用厂商 SystemManager 的installApk(path)可以做到后台直接装------这也依赖前面的系统签名。
最终实现
把 U 盘扫描和安装封装成一个 Repository,关键点都加了防御:
kotlin
class UsbUpdateRepository private constructor() {
data class ApkFileInfo(
val file: File,
val sizeBytes: Long,
val lastModified: Long
)
/** 枚举 U 盘挂载根目录(依赖厂商 U 盘 SDK;无 SDK 时扫描 /storage 兜底) */
fun getMountedRoots(context: Context): List<File> {
return UDiskSdk.getMountRootPaths()
.map(::File)
.filter { it.isDirectory && it.canRead() }
.distinctBy { it.absolutePath }
.sortedBy { it.absolutePath }
}
/** 只扫描根目录一层的 APK,按修改时间倒序 */
fun scanApkFiles(context: Context): List<ApkFileInfo> {
return getMountedRoots(context).asSequence()
.flatMap { root ->
try {
root.listFiles()?.asSequence() ?: emptySequence()
} catch (e: SecurityException) {
emptySequence() // U 盘被拔出瞬间 listFiles 会抛异常
}
}
.filter { it.isFile && it.extension.equals("apk", true) }
.map { ApkFileInfo(it, it.length(), it.lastModified()) }
.sortedByDescending { it.lastModified }
.toList()
}
/** 提交安装请求------厂商系统服务,需系统签名 */
fun installApk(file: File): Result<Unit> = runCatching {
SystemManager.getInstance().installApk(file.absolutePath)
}
}
UI 层是一个"更新文件"列表页,几个细节值得说:
- 插拔自动刷新:监听厂商 SDK 的 U 盘挂载状态回调,拔出时清空列表避免展示"幽灵文件"。
- 内存洁癖 :列表只保存文件元数据(路径/大小/时间),不解析 APK 内容、不读图标 ------我们设备只有 2GB 内存,
PackageManager.getPackageArchiveInfo()解析大 APK 的代价在这种设备上是能省则省。列表用ListAdapter + DiffUtil做局部刷新。 - 未插 U 盘前置拦截:入口按钮点击时先查挂载状态,没挂载直接提示"请先插入U盘",别让用户进空页面。
配套能力:手动重启
系统应用还有一个"隐藏福利":REBOOT 权限 + 厂商工具类,一行代码重启整机:
kotlin
private fun restartSystem() {
try {
// 厂商封装,等价于 PowerManager.reboot()
ESFaceUtils.reboot("用户手动重启")
} catch (e: Exception) {
showToast("重启失败:${e.message}")
}
}
我们在设置页加了"重启系统"入口。别小看这个功能------现场运维遇到玄学问题时,"重启试试"比 adb 命令友好得多。
经验总结
- 设备应用的 OTA 方案要永远留一条物理通道(U 盘/TF 卡)。网络升级是效率,物理升级是兜底,现场没网时它就是唯一手段。
- 静默安装、重启这类能力全部锚定在"系统签名"这一个前提上,所以坑一永远是第一个要解决的问题。
坑五:Windows 下的 Git 长路径------编译产物别进版本库
问题现象
团队协作时有人把模块的 build/ 目录误提交进了 Git 索引。之后在 Windows 上任何涉及该路径的 git 操作(checkout、commit、clone)都可能报:
vbnet
error: unable to create file ...: Filename too long
根因分析
Android 构建产物里 build/.transforms/ 目录会生成极深的嵌套路径,随便一条就能超过 200 字符。Windows 传统 Win32 API 的路径上限是 MAX_PATH = 260 字符,Android 项目根目录再深一点(比如 C:\Users\xxx\AndroidStudioProjects\MyApp\),拼装后直接超限。
Linux/macOS 开发机上这个坑完全不会出现,所以它是典型的"Windows 队友专属问题"。
解决方案
三步,缺一不可:
bash
# 1. 模块根目录补 .gitignore,内容一行:
# /build
# 2. 把已进索引的编译产物移出索引(保留本地文件)
git rm -r --cached <module>/build/
# 3. 开启 Git 长路径支持(对超长路径的既有文件做兼容)
git config core.longpaths true
更彻底的做法是在 Windows 上启用系统级长路径(组策略或注册表 LongPathsEnabled),但那要求应用本身声明 long path aware,对 Git for Windows 来说 core.longpaths 通常就够了。
经验总结
- 新项目脚手架阶段就检查每个模块的
.gitignore,Gradle 模块都应该忽略/build。 git rm -r --cached是关键操作------很多人只加了.gitignore发现没效果,因为已在索引里的文件不受 ignore 规则约束,必须先从索引剔除。
总结:设备应用开发的范式差异清单
回顾这五个坑,它们背后是同一条主线:行业设备应用的问题不在 API 调用,而在"系统层契约"。
| 坑 | 表面现象 | 真实根因 |
|---|---|---|
| 安装失败 | SHARED_USER_INCOMPATIBLE | sharedUserId=system 必须 platform key 签名 |
| SDK 报授权失败 -4 | 授权文件问题 | MANAGE_EXTERNAL_STORAGE 权限缺失,SDK fallback 到旧路径 |
| 开机不自启 | 广播没注册 | stopped state / 后台启动限制,需系统层配合 |
| 现场无法升级 | ------ | 需要厂商 installApk 特权 + U 盘挂载枚举 |
| Git 提交报错 | 文件名过长 | build/ 误入库 + Windows MAX_PATH |
给同样在做 RK/工控/自助终端的同行几条建议:
- 第一天就把 platform key 拿到手,它决定了你后面所有特权能力的可行性,也决定了团队所有人能不能装上包。
- 对所有"读文件的 SDK"建立诊断惯性:报错先打权限状态、路径、文件列表三件套日志,不要信错误码的字面含义。
- 关键路径双通道:自启(Receiver + 系统层配置)、升级(网络 + U 盘),任何单点失效都不致命。
- 设备是固定的,在它上面实测一切:消费 App 那套"兼容一万种机型"的思维可以放下,换成"在这块板子上验证一万次"。
这些坑单个看都不高深,但每一个都真实消耗过我们按天计的时间。希望这份记录能帮你把它们压缩到按小时计。