Android 系统级设备应用踩坑实录:sharedUserId 签名、SDK 授权失败 -4、开机自启与 U 盘 OTA 升级

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,你可以申请 REBOOTWRITE_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")
            // ...
        }
    }
}

经验总结

  1. 一旦声明 sharedUserId="android.uid.system",整个团队的所有构建(包括 CI 打 debug 包)都必须用 platform key 签名,否则真机安装一律失败。这条要写进项目 README 第一行。
  2. 拿到系统签名后,你才有底气在 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" />
  1. 如果只有 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>

需要注意的现实限制

代码简单,坑在系统限制上:

  1. Android 3.1+ 的 stopped state :应用安装后如果从未被用户手动启动过,处于 stopped 状态,静态注册的 BOOT_COMPLETED 收不到。设备首次装机时必须先手动点开一次应用。
  2. Android 12+ 后台启动 Activity 限制 :从广播里直接 startActivity 在原生 Android 12+ 上是受限的(Background activity starts 被禁止)。好在行业设备的 ROM 几乎都是厂商深度定制过的 AOSP,多数会放开这个限制------但一定要在你量产的那块板子上实测,别拿 Pixel 的行为当依据。
  3. 系统 UID 的额外好处 :因为我们是 android.uid.system,很多后台限制天然不适用,这也是系统签名方案的隐性收益之一。
  4. 开机自启只是兜底。真正的商用终端还会配厂商层的"开机启动白名单"或 watchdog 方案(如厂商系统设置里把应用设为默认 Launcher、或 init.rc 里拉起)。应用层 Receiver 和系统层配置是互补关系,不是替代关系,建议两条都做,任何一条失效都有备份。

经验总结

行业设备的"开机自启"和消费 App 的"保活"是两个命题:前者是合法需求且系统提供正规路径,后者是灰色对抗。别把消费端的"防杀进程"那一套黑魔法带进设备项目------你手里有 platform key 和厂商关系,走正门。


坑四:U 盘 OTA 升级------现场没有网,怎么更新程序?

场景

智能柜终端部署在工厂、园区,现场经常没有外网,MQTT/HTTP 远程升级不可用。运维人员拿着 U 盘去现场升级是最现实的方式。需求拆开是三件事:检测 U 盘挂载 → 列出根目录的 APK → 点击后调系统能力安装。

根因层面的难点

  1. U 盘挂载路径不固定 。Android 没有官方 API 枚举 USB 存储挂载点,RK 这类板子通常挂在 /mnt/media_rw/xxx/storage/xxxx-xxxx 下,不同固件版本路径还不一样。我们用的是主板厂商配套的 U 盘文件选择 SDK 来获取挂载根路径,没有这类 SDK 时可以退化为扫描 /storage/mnt/media_rw 目录下可读子目录。
  2. 静默安装是特权能力 。普通应用装 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 命令友好得多。

经验总结

  1. 设备应用的 OTA 方案要永远留一条物理通道(U 盘/TF 卡)。网络升级是效率,物理升级是兜底,现场没网时它就是唯一手段。
  2. 静默安装、重启这类能力全部锚定在"系统签名"这一个前提上,所以坑一永远是第一个要解决的问题。

坑五: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/工控/自助终端的同行几条建议:

  1. 第一天就把 platform key 拿到手,它决定了你后面所有特权能力的可行性,也决定了团队所有人能不能装上包。
  2. 对所有"读文件的 SDK"建立诊断惯性:报错先打权限状态、路径、文件列表三件套日志,不要信错误码的字面含义。
  3. 关键路径双通道:自启(Receiver + 系统层配置)、升级(网络 + U 盘),任何单点失效都不致命。
  4. 设备是固定的,在它上面实测一切:消费 App 那套"兼容一万种机型"的思维可以放下,换成"在这块板子上验证一万次"。

这些坑单个看都不高深,但每一个都真实消耗过我们按天计的时间。希望这份记录能帮你把它们压缩到按小时计。


相关推荐
IT毕设实战小研4 小时前
基于大数据的国内主要农作物产量趋势分析与可视化
android·java·大数据·django·课程设计
企业数字化笔记5 小时前
固定资产历史数据怎么导入系统?Excel模板、字段映射和数据校验
android·java·数据库·后端
龙之叶6 小时前
Android出海系列-GMS认证介绍
android
潮族大Z6 小时前
Android性能优化:启动、内存、卡顿的一站式排查手册
android
事圆则缓7 小时前
从suspend字节码到 Retrofit 协程桥:挂起函数识别与恢复
android·retrofit
杉氧8 小时前
RN 性能调优指南:重渲染(Re-renders)控制与长列表(FlatList)优化
android·前端·react native
Coffeeee8 小时前
claude-video 一个让你的Agent拥有看视频能力的Skill
android·人工智能·aigc
菜鸟~noob23310 小时前
【电子战】第07篇:多普勒测向【含matlab代码】
android·开发语言·matlab
样子201810 小时前
Js 之根据白名单过滤 HTML(防止 XSS 攻击)
android·前端·javascript·html·xss